{ "openapi": "3.1.0", "info": { "title": "Bunkai TMS API", "version": "0.1.0", "description": "Bunkai TMS public API. Wraps Supabase auth, ATC authoring, and run execution behind a versioned `/api/v1` surface. Designed to be operated by humans, scripts, CI/CD, and AI agents.", "contact": { "name": "Bunkai", "url": "https://bunkai.io" } }, "servers": [ { "url": "http://localhost:3000", "description": "Local dev" }, { "url": "https://staging-upexbunkai.vercel.app", "description": "Staging" }, { "url": "https://upexbunkai.vercel.app", "description": "Production" } ], "tags": [ { "name": "Health", "description": "Service liveness probes." }, { "name": "Auth", "description": "Authentication and session bootstrap." }, { "name": "Tokens", "description": "Personal access token issuance and revocation." } ], "components": { "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "description": "Personal access token. Format: `bk_pat_<12-char-prefix>.`. Issue via `POST /api/v1/tokens`." }, "cookieAuth": { "type": "apiKey", "in": "cookie", "name": "sb-fmbpikzpkafptqximhxn-auth-token", "description": "Supabase session cookie. Set automatically after the magic-link callback in the web app. Used for browser-driven calls." }, "cronAuth": { "type": "http", "scheme": "bearer", "description": "System secret (`CRON_SECRET`). Internal only — used by the scheduler that triggers this route, never issued to end users." } }, "schemas": { "ErrorEnvelope": { "type": "object", "properties": { "error": { "type": "object", "properties": { "code": { "type": "string", "enum": [ "bad_request", "validation_failed", "unauthorized", "forbidden", "not_found", "method_not_allowed", "conflict", "idempotency_key_required", "idempotency_key_invalid", "rate_limited", "ac_outside_user_story", "module_outside_project_subtree", "steps_position_invalid", "slug_collision", "chain_empty", "chain_mismatch", "chain_invalid", "no_executable_steps", "environment_invalid", "test_outside_plan_project", "internal_error", "upstream_error" ], "description": "Machine-readable error code. Branch on this value, not on `message`." }, "message": { "type": "string", "description": "Human-readable error description." }, "details": { "description": "Optional structured details. For validation errors this is the ZodError issues array." }, "request_id": { "type": "string", "description": "Echoed `x-request-id`. Quote this in bug reports." } }, "required": ["code", "message"] } }, "required": ["error"], "description": "Canonical error envelope returned by every `/api/v1` route on failure." }, "AcceptanceCriterion": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "user_story_id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "description": { "type": ["string", "null"] }, "position": { "type": "integer" }, "created_at": { "type": "string", "format": "date-time" }, "archived_at": { "type": ["string", "null"], "format": "date-time" } }, "required": [ "id", "user_story_id", "title", "description", "position", "created_at", "archived_at" ] }, "AcceptanceCriterionCreateBody": { "type": "object", "properties": { "title": { "type": "string", "description": "3–200 chars." }, "description": { "type": "string", "description": "Optional Markdown, up to 50 KB. Sanitized on save." }, "position": { "type": "integer", "exclusiveMinimum": 0, "description": "1-based insert position; appended at the tail when omitted. Active siblings shift down atomically." } }, "required": ["title"] }, "AcceptanceCriterionUpdateBody": { "type": "object", "properties": { "title": { "type": "string", "description": "3–200 chars." }, "description": { "type": ["string", "null"], "description": "Markdown, up to 50 KB; null clears it." }, "position": { "type": "integer", "exclusiveMinimum": 0, "description": "1-based target position. The active set is re-numbered contiguously around it (no gaps)." } } }, "ActivityPage": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/ActivityItem" }, "description": "The page, ordered newest first (created_at desc, id desc)." }, "next_cursor": { "type": ["string", "null"], "description": "Opaque token for the next (older) page, or null when this is the last page. base64url — echo it back verbatim as `?cursor=`; never construct or parse one." } }, "required": ["items", "next_cursor"] }, "ActivityItem": { "oneOf": [ { "$ref": "#/components/schemas/ActivityItemModuleRenamed" }, { "$ref": "#/components/schemas/ActivityItemModuleDescriptionUpdated" }, { "$ref": "#/components/schemas/ActivityItemModuleMoved" }, { "$ref": "#/components/schemas/ActivityItemModuleArchived" }, { "$ref": "#/components/schemas/ActivityItemAtcCreated" }, { "$ref": "#/components/schemas/ActivityItemTestCreated" }, { "$ref": "#/components/schemas/ActivityItemRunFinished" }, { "$ref": "#/components/schemas/ActivityItemRunAborted" }, { "$ref": "#/components/schemas/ActivityItemBugAssigned" }, { "$ref": "#/components/schemas/ActivityItemBugReassigned" }, { "$ref": "#/components/schemas/ActivityItemBugUnassigned" }, { "$ref": "#/components/schemas/ActivityItemBugStatusChanged" } ], "discriminator": { "propertyName": "action", "mapping": { "module.renamed": "#/components/schemas/ActivityItemModuleRenamed", "module.description_updated": "#/components/schemas/ActivityItemModuleDescriptionUpdated", "module.moved": "#/components/schemas/ActivityItemModuleMoved", "module.archived": "#/components/schemas/ActivityItemModuleArchived", "atc.created": "#/components/schemas/ActivityItemAtcCreated", "test.created": "#/components/schemas/ActivityItemTestCreated", "run.finished": "#/components/schemas/ActivityItemRunFinished", "run.aborted": "#/components/schemas/ActivityItemRunAborted", "bug.assigned": "#/components/schemas/ActivityItemBugAssigned", "bug.reassigned": "#/components/schemas/ActivityItemBugReassigned", "bug.unassigned": "#/components/schemas/ActivityItemBugUnassigned", "bug.status_changed": "#/components/schemas/ActivityItemBugStatusChanged" } } }, "ActivityItemModuleRenamed": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["module"] }, "action": { "type": "string", "enum": ["module.renamed"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadModuleRenamed" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityActor": { "type": "object", "properties": { "user_id": { "type": ["string", "null"], "format": "uuid", "description": "null when the row has no actor (a system-originated event)." }, "email": { "type": ["string", "null"], "description": "null when the actor is unresolvable (e.g. a departed member) — a safe fallback, never a crash." } }, "required": ["user_id", "email"] }, "ActivityItemLabel": { "type": "object", "properties": { "label": { "type": "string", "description": "Payload-derived where a usable field exists (e.g. a title, a path); the generic \"a \" fallback otherwise." }, "entity_id": { "type": ["string", "null"], "format": "uuid" } }, "required": ["label", "entity_id"] }, "ActivityPayloadModuleRenamed": { "type": "object", "properties": { "name": { "type": ["string", "null"] }, "new_path": { "type": ["string", "null"] } }, "required": ["name", "new_path"] }, "ActivityItemModuleDescriptionUpdated": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["module"] }, "action": { "type": "string", "enum": ["module.description_updated"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadModuleDescriptionUpdated" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadModuleDescriptionUpdated": { "type": "object", "properties": {} }, "ActivityItemModuleMoved": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["module"] }, "action": { "type": "string", "enum": ["module.moved"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadModuleMoved" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadModuleMoved": { "type": "object", "properties": { "new_path": { "type": ["string", "null"] } }, "required": ["new_path"] }, "ActivityItemModuleArchived": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["module"] }, "action": { "type": "string", "enum": ["module.archived"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadModuleArchived" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadModuleArchived": { "type": "object", "properties": { "modules": { "type": ["integer", "null"] }, "user_stories": { "type": ["integer", "null"] }, "acceptance_criteria": { "type": ["integer", "null"] }, "atcs": { "type": ["integer", "null"] } }, "required": ["modules", "user_stories", "acceptance_criteria", "atcs"] }, "ActivityItemAtcCreated": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["atc"] }, "action": { "type": "string", "enum": ["atc.created"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadAtcCreated" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadAtcCreated": { "type": "object", "properties": { "title": { "type": ["string", "null"] } }, "required": ["title"] }, "ActivityItemTestCreated": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["test"] }, "action": { "type": "string", "enum": ["test.created"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadTestCreated" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadTestCreated": { "type": "object", "properties": { "title": { "type": ["string", "null"] } }, "required": ["title"] }, "ActivityItemRunFinished": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["run"] }, "action": { "type": "string", "enum": ["run.finished"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadRunFinished" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadRunFinished": { "type": "object", "properties": { "verdict": { "type": ["string", "null"], "enum": ["passed", "failed", null] }, "skipped_steps": { "type": ["integer", "null"] } }, "required": ["verdict", "skipped_steps"] }, "ActivityItemRunAborted": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["run"] }, "action": { "type": "string", "enum": ["run.aborted"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadRunAborted" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadRunAborted": { "type": "object", "properties": { "skipped_steps": { "type": ["integer", "null"] } }, "required": ["skipped_steps"] }, "ActivityItemBugAssigned": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["bug"] }, "action": { "type": "string", "enum": ["bug.assigned"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadBugAssigned" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadBugAssigned": { "type": "object", "properties": { "previous_assignee_user_id": { "type": ["string", "null"], "format": "uuid" }, "assignee_user_id": { "type": ["string", "null"], "format": "uuid" } }, "required": ["previous_assignee_user_id", "assignee_user_id"] }, "ActivityItemBugReassigned": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["bug"] }, "action": { "type": "string", "enum": ["bug.reassigned"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadBugReassigned" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadBugReassigned": { "type": "object", "properties": { "previous_assignee_user_id": { "type": ["string", "null"], "format": "uuid" }, "assignee_user_id": { "type": ["string", "null"], "format": "uuid" } }, "required": ["previous_assignee_user_id", "assignee_user_id"] }, "ActivityItemBugUnassigned": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["bug"] }, "action": { "type": "string", "enum": ["bug.unassigned"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadBugUnassigned" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadBugUnassigned": { "type": "object", "properties": { "previous_assignee_user_id": { "type": ["string", "null"], "format": "uuid" }, "assignee_user_id": { "type": ["string", "null"], "format": "uuid" } }, "required": ["previous_assignee_user_id", "assignee_user_id"] }, "ActivityItemBugStatusChanged": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "activity_log row id — stable React key." }, "entity_type": { "type": "string", "enum": ["bug"] }, "action": { "type": "string", "enum": ["bug.status_changed"] }, "action_label": { "type": "string", "description": "Server-rendered, from ACTION_LABELS — never re-derived client-side." }, "actor": { "$ref": "#/components/schemas/ActivityActor" }, "item": { "$ref": "#/components/schemas/ActivityItemLabel" }, "payload": { "$ref": "#/components/schemas/ActivityPayloadBugStatusChanged" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "entity_type", "action", "action_label", "actor", "item", "payload", "created_at" ] }, "ActivityPayloadBugStatusChanged": { "type": "object", "properties": { "previous_status": { "type": ["string", "null"], "enum": ["open", "in_progress", "resolved", "closed", null] }, "status": { "type": ["string", "null"], "enum": ["open", "in_progress", "resolved", "closed", null] }, "assignee_user_id": { "type": ["string", "null"], "format": "uuid" } }, "required": ["previous_status", "status", "assignee_user_id"] }, "SendDigestResponse": { "type": "object", "properties": { "eligible_users": { "type": "integer", "description": "Distinct recipients with at least one eligible unread notification." }, "sent": { "type": "integer" }, "failed": { "type": "integer" }, "skipped": { "type": "integer", "description": "Already claimed for today (same-day re-invocation) — not a failure." } }, "required": ["eligible_users", "sent", "failed", "skipped"] }, "Atc": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "module_id": { "type": "string", "format": "uuid" }, "user_story_id": { "type": "string", "format": "uuid" }, "slug": { "type": "string", "description": "`/atc-<8 hex>`. Computed once at creation, immutable across edits." }, "title": { "type": "string" }, "layer": { "type": "string", "enum": ["UI", "API", "Unit"] }, "technique": { "type": ["string", "null"], "enum": [ "Equivalence Partitioning", "Boundary Value Analysis", "State Transition", "Decision Table", "Pairwise", null ], "description": "BK-399 — test-design technique that produced this ATC. `null` means \"not specified\"; it is never defaulted to a real value." }, "priority": { "type": ["string", "null"], "enum": ["Critical", "High", "Medium", "Low", null], "description": "BK-399 — ATC priority (distinct from Bug severity). `null` means \"not specified\"." }, "version": { "type": "integer", "description": "Monotonic per ATC. Starts at 1, +1 per edit." }, "status": { "type": "string", "enum": ["pass", "fail", "blocked", "skipped", "running", "unrun"] }, "tags": { "type": "array", "items": { "type": "string" } }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "archived_at": { "type": ["string", "null"], "format": "date-time" }, "steps": { "type": "array", "items": { "$ref": "#/components/schemas/AtcStep" } }, "assertions": { "type": "array", "items": { "$ref": "#/components/schemas/AtcAssertion" } }, "acceptance_criterion_ids": { "type": "array", "items": { "type": "string", "format": "uuid" } } }, "required": [ "id", "project_id", "module_id", "user_story_id", "slug", "title", "layer", "technique", "priority", "version", "status", "tags", "created_at", "updated_at", "archived_at", "steps", "assertions", "acceptance_criterion_ids" ] }, "AtcStep": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "position": { "type": "integer" }, "content": { "type": "string" }, "input_data": { "type": ["string", "null"] }, "expected": { "type": ["string", "null"] } }, "required": ["id", "position", "content", "input_data", "expected"] }, "AtcAssertion": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "position": { "type": "integer" }, "content": { "type": "string" } }, "required": ["id", "position", "content"] }, "AtcCreateBody": { "type": "object", "properties": { "title": { "type": "string", "minLength": 3, "maxLength": 200, "description": "3-200 characters after trimming leading/trailing whitespace (BK-622)." }, "module_id": { "type": "string", "format": "uuid" }, "user_story_id": { "type": "string", "format": "uuid" }, "acceptance_criterion_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1, "description": "≥1; all must belong to user_story_id." }, "layer": { "type": "string", "enum": ["UI", "API", "Unit"] }, "technique": { "type": ["string", "null"], "enum": [ "Equivalence Partitioning", "Boundary Value Analysis", "State Transition", "Decision Table", "Pairwise", null ], "description": "Optional test-design technique. Omitted keys are cleared, not merged: sending `null` or omitting the key stores \"not specified\" (SQL NULL). Exact display label, case-sensitive, not trimmed." }, "priority": { "type": ["string", "null"], "enum": ["Critical", "High", "Medium", "Low", null], "description": "Optional ATC priority (distinct from Bug severity). Omitted keys are cleared, not merged: sending `null` or omitting the key stores \"not specified\" (SQL NULL). Exact display label, case-sensitive, not trimmed." }, "tags": { "type": "array", "items": { "type": "string" }, "maxItems": 10 }, "steps": { "type": "array", "items": { "type": "object", "properties": { "position": { "type": "integer", "description": "Integer, strictly increasing from 1." }, "content": { "type": "string", "minLength": 1, "maxLength": 2048, "description": "Up to 2 KB Markdown." }, "input_data": { "type": ["string", "null"], "maxLength": 2048 }, "expected": { "type": ["string", "null"], "maxLength": 2048 } }, "required": ["position", "content"] }, "minItems": 1 }, "assertions": { "type": "array", "items": { "type": "object", "properties": { "content": { "type": "string", "minLength": 1, "maxLength": 2048 } }, "required": ["content"] } } }, "required": [ "title", "module_id", "user_story_id", "acceptance_criterion_ids", "layer", "steps" ] }, "AtcDuplicateBody": { "type": "object", "properties": { "new_title": { "type": "string", "minLength": 3, "maxLength": 200, "description": "Optional title for the copy. Omit to default to ` (copy)`. 3-200 characters after trimming leading/trailing whitespace (BK-622)." } } }, "AtcUpdateBody": { "type": "object", "properties": { "title": { "type": "string", "minLength": 3, "maxLength": 200, "description": "3-200 characters after trimming leading/trailing whitespace (BK-622)." }, "layer": { "type": "string", "enum": ["UI", "API", "Unit"] }, "technique": { "type": ["string", "null"], "enum": [ "Equivalence Partitioning", "Boundary Value Analysis", "State Transition", "Decision Table", "Pairwise", null ], "description": "Optional test-design technique. Omitted keys are cleared, not merged: sending `null` or omitting the key stores \"not specified\" (SQL NULL). Exact display label, case-sensitive, not trimmed." }, "priority": { "type": ["string", "null"], "enum": ["Critical", "High", "Medium", "Low", null], "description": "Optional ATC priority (distinct from Bug severity). Omitted keys are cleared, not merged: sending `null` or omitting the key stores \"not specified\" (SQL NULL). Exact display label, case-sensitive, not trimmed." }, "tags": { "type": "array", "items": { "type": "string" }, "maxItems": 10 }, "steps": { "type": "array", "items": { "type": "object", "properties": { "position": { "type": "integer", "description": "Integer, strictly increasing from 1." }, "content": { "type": "string", "minLength": 1, "maxLength": 2048 }, "input_data": { "type": ["string", "null"], "maxLength": 2048 }, "expected": { "type": ["string", "null"], "maxLength": 2048 } }, "required": ["position", "content"] }, "minItems": 1 }, "assertions": { "type": "array", "items": { "type": "object", "properties": { "content": { "type": "string", "minLength": 1, "maxLength": 2048 } }, "required": ["content"] } }, "acceptance_criterion_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1 } }, "required": ["title", "layer", "steps", "acceptance_criterion_ids"] }, "AtcUsageReport": { "type": "object", "properties": { "count": { "type": "integer", "description": "Number of distinct Tests that chain this ATC." }, "used_in": { "type": "array", "items": { "$ref": "#/components/schemas/AtcUsageEntry" } } }, "required": ["count", "used_in"] }, "AtcUsageEntry": { "type": "object", "properties": { "test_id": { "type": "string", "format": "uuid" }, "title": { "type": "string", "description": "The Test title (Tests have no slug — ordered by title asc)." }, "positions": { "type": "array", "items": { "type": "integer" }, "description": "Ascending positions the ATC occupies in this Test." } }, "required": ["test_id", "title", "positions"] }, "AtcSearchResult": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string" }, "title": { "type": "string" }, "layer": { "type": "string", "enum": ["UI", "API", "Unit"] }, "status": { "type": "string", "enum": ["pass", "fail", "blocked", "skipped", "running", "unrun"] }, "module_path": { "type": "string", "description": "Slash-separated module breadcrumb." } }, "required": ["id", "slug", "title", "layer", "status", "module_path"] }, "CheckEmailResponse": { "type": "object", "properties": { "exists": { "type": "boolean" }, "confirmed": { "type": "boolean" } }, "required": ["exists", "confirmed"] }, "CheckEmailBody": { "type": "object", "properties": { "email": { "type": "string", "maxLength": 254, "format": "email", "example": "qa.user@example.com" } }, "required": ["email"] }, "ConfirmResponse": { "type": "object", "properties": { "user": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "email": { "type": ["string", "null"], "format": "email" } }, "required": ["id", "email"] }, "session": { "type": "object", "properties": { "access_token": { "type": "string" }, "refresh_token": { "type": "string" }, "expires_at": { "type": "number" }, "token_type": { "type": "string" } }, "required": ["access_token", "refresh_token"] }, "pat": { "type": "object", "properties": { "token": { "type": "string" }, "id": { "type": "string", "format": "uuid" }, "name": { "type": ["string", "null"] }, "scopes": { "type": "array", "items": { "type": "string", "enum": ["atc:read", "atc:write", "run:execute", "workspace:admin"] } }, "expires_at": { "type": ["string", "null"], "format": "date-time" } }, "required": ["token", "id", "name", "scopes", "expires_at"] }, "warning": { "type": "string" } }, "required": ["user", "session", "pat", "warning"] }, "ConfirmBody": { "type": "object", "properties": { "email": { "type": "string", "maxLength": 254, "format": "email", "example": "qa.user@example.com" }, "token": { "type": "string", "pattern": "^\\d{6,8}$", "description": "Numeric email OTP, 6 to 8 digits.", "example": "12345678" }, "pat_name": { "type": "string", "minLength": 1, "maxLength": 80 }, "pat_scopes": { "type": "array", "items": { "type": "string", "enum": ["atc:read", "atc:write", "run:execute", "workspace:admin"] }, "description": "`workspace:admin` is NOT accepted here and returns 403 `forbidden`, even though the enum lists it — the enum is the shared capability vocabulary, not the set this route grants. Headless auth carries no `workspace_id`, and an admin-scoped token must target one specific workspace, so the guard rejects the scope outright. Mint admin-scoped tokens through `POST /api/v1/tokens` with a `workspace_id` instead. Omit this field to get the defaults: `atc:read`, `atc:write`, `run:execute`. See ADR-0005." }, "pat_expires_in_days": { "type": "integer", "exclusiveMinimum": 0, "maximum": 365 } }, "required": ["email", "token"] }, "MagicLinkResponse": { "type": "object", "properties": { "ok": { "type": "boolean", "enum": [true] } }, "required": ["ok"] }, "MagicLinkBody": { "type": "object", "properties": { "email": { "type": "string", "maxLength": 254, "format": "email", "example": "tester@bunkai.io" }, "next": { "type": "string", "minLength": 1, "description": "Root-relative path to land on after the OTP exchange. Defaults to `/projects`.", "example": "/projects" } }, "required": ["email"] }, "ResendResponse": { "type": "object", "properties": { "status": { "type": "string", "enum": ["sent"] }, "email": { "type": "string", "format": "email" } }, "required": ["status", "email"] }, "ResendBody": { "type": "object", "properties": { "email": { "type": "string", "maxLength": 254, "format": "email", "example": "qa.user@example.com" } }, "required": ["email"] }, "SigninResponse": { "type": "object", "properties": { "user": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "email": { "type": ["string", "null"], "format": "email" } }, "required": ["id", "email"] }, "session": { "type": "object", "properties": { "access_token": { "type": "string" }, "refresh_token": { "type": "string" }, "expires_at": { "type": "number" }, "token_type": { "type": "string" } }, "required": ["access_token", "refresh_token"] }, "pat": { "type": "object", "properties": { "token": { "type": "string" }, "id": { "type": "string", "format": "uuid" }, "name": { "type": ["string", "null"] }, "scopes": { "type": "array", "items": { "type": "string", "enum": ["atc:read", "atc:write", "run:execute", "workspace:admin"] } }, "expires_at": { "type": ["string", "null"], "format": "date-time" } }, "required": ["token", "id", "name", "scopes", "expires_at"] }, "warning": { "type": "string" } }, "required": ["user", "session", "pat", "warning"] }, "SigninBody": { "type": "object", "properties": { "email": { "type": "string", "maxLength": 254, "format": "email", "example": "qa.user@example.com" }, "password": { "type": "string", "minLength": 6, "maxLength": 128 }, "pat_name": { "type": "string", "minLength": 1, "maxLength": 80 }, "pat_scopes": { "type": "array", "items": { "type": "string", "enum": ["atc:read", "atc:write", "run:execute", "workspace:admin"] }, "description": "`workspace:admin` is NOT accepted here and returns 403 `forbidden`, even though the enum lists it — the enum is the shared capability vocabulary, not the set this route grants. Headless auth carries no `workspace_id`, and an admin-scoped token must target one specific workspace, so the guard rejects the scope outright. Mint admin-scoped tokens through `POST /api/v1/tokens` with a `workspace_id` instead. Omit this field to get the defaults: `atc:read`, `atc:write`, `run:execute`. See ADR-0005." }, "pat_expires_in_days": { "type": "integer", "exclusiveMinimum": 0, "maximum": 365 } }, "required": ["email", "password"] }, "SignupResponse": { "type": "object", "properties": { "status": { "type": "string", "enum": ["pending_confirmation"] }, "email": { "type": "string", "format": "email" } }, "required": ["status", "email"] }, "SignupBody": { "type": "object", "properties": { "email": { "type": "string", "maxLength": 254, "format": "email", "example": "qa.user@example.com" }, "password": { "type": "string", "minLength": 8, "maxLength": 128 } }, "required": ["email", "password"] }, "BugDetail": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "workspace_id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "module_id": { "type": "string", "format": "uuid" }, "run_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source Run; null for a standalone bug." }, "run_step_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source run step; null for a standalone bug." }, "atc_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source ATC; null for a standalone bug." }, "title": { "type": "string" }, "severity": { "type": "string", "enum": ["P1", "P2", "P3", "P4"] }, "status": { "type": "string", "enum": ["open", "in_progress", "resolved", "closed"], "description": "BK-40 always creates `open`; the other states are the lifecycle this table already supports for later stories. BK-264 adds POST /api/v1/bugs/{id}/status to advance it one stage at a time." }, "description": { "type": ["string", "null"] }, "steps_to_reproduce": { "type": "string" }, "evidence_urls": { "type": "array", "items": { "type": "string", "format": "uri" }, "maxItems": 10 }, "assignee_user_id": { "type": ["string", "null"], "format": "uuid", "description": "BK-264 — the workspace member currently assigned to this bug, or null if unassigned. Always null immediately after creation (BK-40 never accepts an assignee at filing time). Set via POST /api/v1/bugs/{id}/assign." }, "created_by": { "type": ["string", "null"], "format": "uuid" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "module": { "$ref": "#/components/schemas/BugDetailModule" }, "origin": { "$ref": "#/components/schemas/BugOrigin" } }, "required": [ "id", "workspace_id", "project_id", "module_id", "run_id", "run_step_id", "atc_id", "title", "severity", "status", "description", "steps_to_reproduce", "evidence_urls", "assignee_user_id", "created_by", "created_at", "updated_at", "module", "origin" ] }, "BugDetailModule": { "allOf": [ { "$ref": "#/components/schemas/BugModule" }, { "type": "object", "properties": { "archived_at": { "type": ["string", "null"], "format": "date-time", "description": "Set when this defect's module was archived after filing. The single-bug read does NOT exclude archived-module defects the way the list endpoints do (PO ruling — render the record, tag the module, never 404 it)." } }, "required": ["archived_at"] } ] }, "BugModule": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "path": { "type": "string" } }, "required": ["id", "name", "path"] }, "BugOrigin": { "type": ["object", "null"], "properties": { "run_id": { "type": "string", "format": "uuid" }, "run_step_position": { "type": ["integer", "null"] }, "atc_id": { "type": ["string", "null"], "format": "uuid" }, "atc_title": { "type": ["string", "null"] }, "atc_layer": { "type": ["string", "null"], "enum": ["UI", "API", "Unit", null] } }, "required": ["run_id", "run_step_position", "atc_id", "atc_title", "atc_layer"], "description": "Provenance for a run-linked defect (originating Run + failed step position + ATC), or null for a standalone one — the page reads null as \"Filed manually\" (PO ruling)." }, "BugRunLinkedCreateBody": { "type": "object", "properties": { "run_step_id": { "type": "string", "format": "uuid", "description": "The failed run step this bug is filed from. project_id/module_id/run_id/atc_id are ALWAYS derived server-side from this — never accept them from the client on this path." }, "title": { "type": "string", "minLength": 5, "maxLength": 200 }, "severity": { "type": "string", "enum": ["P1", "P2", "P3", "P4"] }, "description": { "type": "string" }, "steps_to_reproduce": { "type": "string" }, "evidence_urls": { "type": "array", "items": { "type": "string", "format": "uri" }, "maxItems": 10, "description": "Up to 10 evidence links. Each entry must use the `http://` or `https://` scheme — the validator is protocol-restricted, so `ftp://`, `data:`, `file://` and any other scheme are rejected with 422 `validation_failed` even though the value is a well-formed URL." } }, "required": ["run_step_id", "title", "severity"] }, "BugStandaloneCreateBody": { "type": "object", "properties": { "project_id": { "type": "string", "format": "uuid" }, "module_id": { "type": "string", "format": "uuid" }, "title": { "type": "string", "minLength": 5, "maxLength": 200 }, "severity": { "type": "string", "enum": ["P1", "P2", "P3", "P4"] }, "description": { "type": "string" }, "steps_to_reproduce": { "type": "string" }, "evidence_urls": { "type": "array", "items": { "type": "string", "format": "uri" }, "maxItems": 10, "description": "Up to 10 evidence links. Each entry must use the `http://` or `https://` scheme — the validator is protocol-restricted, so `ftp://`, `data:`, `file://` and any other scheme are rejected with 422 `validation_failed` even though the value is a well-formed URL." } }, "required": ["project_id", "module_id", "title", "severity"] }, "BugsListPage": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/BugListItem" }, "description": "The page, ordered severity ascending (P1..P4) then created_at desc, id desc (Decision 5). Each item carries a resolved `assignee` (BK-264), the same way GET /api/v1/activity resolves actor display info." }, "aggregates": { "$ref": "#/components/schemas/BugsAggregates" }, "next_cursor": { "type": ["string", "null"], "description": "Opaque token for the next page, or null when this is the last page. base64url — echo it back verbatim as `?cursor=`; never construct or parse one." } }, "required": ["data", "aggregates", "next_cursor"] }, "BugListItem": { "allOf": [ { "$ref": "#/components/schemas/Bug" }, { "type": "object", "properties": { "assignee": { "$ref": "#/components/schemas/BugAssignee" } }, "required": ["assignee"] } ] }, "BugAssignee": { "type": ["object", "null"], "properties": { "user_id": { "type": "string", "format": "uuid" }, "email": { "type": ["string", "null"], "format": "email" } }, "required": ["user_id", "email"], "description": "Resolved display info for `assignee_user_id`, or null when unassigned." }, "Bug": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "workspace_id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "module_id": { "type": "string", "format": "uuid" }, "module": { "$ref": "#/components/schemas/BugModule" }, "run_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source Run; null for a standalone bug." }, "run_step_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source run step; null for a standalone bug." }, "atc_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source ATC; null for a standalone bug." }, "title": { "type": "string" }, "severity": { "type": "string", "enum": ["P1", "P2", "P3", "P4"] }, "status": { "type": "string", "enum": ["open", "in_progress", "resolved", "closed"], "description": "BK-40 always creates `open`; the other states are the lifecycle this table already supports for later stories. BK-264 adds POST /api/v1/bugs/{id}/status to advance it one stage at a time." }, "description": { "type": ["string", "null"] }, "steps_to_reproduce": { "type": "string" }, "evidence_urls": { "type": "array", "items": { "type": "string", "format": "uri" }, "maxItems": 10 }, "assignee_user_id": { "type": ["string", "null"], "format": "uuid", "description": "BK-264 — the workspace member currently assigned to this bug, or null if unassigned. Always null immediately after creation (BK-40 never accepts an assignee at filing time). Set via POST /api/v1/bugs/{id}/assign." }, "created_by": { "type": ["string", "null"], "format": "uuid" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "workspace_id", "project_id", "module_id", "module", "run_id", "run_step_id", "atc_id", "title", "severity", "status", "description", "steps_to_reproduce", "evidence_urls", "assignee_user_id", "created_by", "created_at", "updated_at" ] }, "BugsAggregates": { "type": "object", "properties": { "by_severity": { "type": "object", "properties": { "P1": { "type": "integer" }, "P2": { "type": "integer" }, "P3": { "type": "integer" }, "P4": { "type": "integer" } }, "required": ["P1", "P2", "P3", "P4"] }, "by_status": { "type": "object", "properties": { "open": { "type": "integer" }, "in_progress": { "type": "integer" }, "resolved": { "type": "integer" }, "closed": { "type": "integer" } }, "required": ["open", "in_progress", "resolved", "closed"] } }, "required": ["by_severity", "by_status"] }, "BugAssignBody": { "type": "object", "properties": { "assignee_user_id": { "type": ["string", "null"], "format": "uuid", "description": "The workspace member to assign this bug to, or null to unassign. Must be an active, non-viewer member of the bug's own workspace." } }, "required": ["assignee_user_id"] }, "BugStatusTransitionBody": { "type": "object", "properties": { "status": { "type": "string", "enum": ["open", "in_progress", "resolved", "closed"] } }, "required": ["status"] }, "ProjectEnvironmentRenameResponse": { "type": "object", "properties": { "environment": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "name": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" } }, "required": ["id", "project_id", "name", "created_at"] } }, "required": ["environment"] }, "ProjectEnvironmentRenameBody": { "type": "object", "properties": { "name": { "type": "string", "description": "1–50 chars after trim. Unique per project, case-insensitive." } }, "required": ["name"] }, "ProjectEnvironmentDeleteResponse": { "type": "object", "properties": { "deleted": { "type": "object", "properties": { "deleted": { "type": "boolean" }, "id": { "type": "string", "format": "uuid" } }, "required": ["deleted", "id"] } }, "required": ["deleted"] }, "HealthResponse": { "type": "object", "properties": { "ok": { "type": "boolean", "enum": [true] }, "service": { "type": "string", "example": "bunkai-tms" }, "env": { "type": "string", "enum": ["local", "staging", "production"] }, "ts": { "type": "string", "format": "date-time", "description": "ISO 8601 timestamp of the probe." } }, "required": ["ok", "service", "env", "ts"] }, "ImportCreateBody": { "type": "object", "properties": { "project_id": { "type": "string", "format": "uuid" }, "jql": { "type": "string", "description": "Jira JQL. Issues are imported into the project; components route to Modules by name (else Inbox)." } }, "required": ["project_id", "jql"] }, "ImportJob": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "workspace_id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "jql": { "type": "string" }, "status": { "type": "string", "enum": ["queued", "running", "completed", "failed"] }, "imported_count": { "type": "integer" }, "created_count": { "type": "integer" }, "updated_count": { "type": "integer" }, "skipped_count": { "type": "integer" }, "errors": { "type": "array", "items": { "$ref": "#/components/schemas/ImportJobError" } }, "started_at": { "type": ["string", "null"], "format": "date-time" }, "completed_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "workspace_id", "project_id", "jql", "status", "imported_count", "created_count", "updated_count", "skipped_count", "errors", "started_at", "completed_at", "created_at" ] }, "ImportJobError": { "type": "object", "properties": { "jira_key": { "type": "string" }, "code": { "type": "string" }, "message": { "type": "string" } }, "required": ["code"] }, "InviteAcceptResponse": { "type": "object", "properties": { "ok": { "type": "boolean", "enum": [true] }, "workspace_id": { "type": "string", "format": "uuid" }, "role": { "type": "string", "enum": ["viewer", "member", "admin"] } }, "required": ["ok", "workspace_id", "role"] }, "InviteAcceptBody": { "type": "object", "properties": { "token": { "type": "string", "minLength": 8, "maxLength": 256 } }, "required": ["token"] }, "ActiveWorkspaceResponse": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string" }, "name": { "type": "string" }, "role": { "type": ["string", "null"] } }, "required": ["id", "slug", "name", "role"] }, "ActiveWorkspaceBody": { "type": "object", "properties": { "workspace_id": { "type": "string", "format": "uuid" } }, "required": ["workspace_id"] }, "MeResponse": { "type": "object", "properties": { "user": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "email": { "type": ["string", "null"], "format": "email" } }, "required": ["id", "email"] }, "workspaces": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string" }, "name": { "type": "string" }, "plan": { "type": "string", "enum": ["community", "cloud", "enterprise"] }, "owner_user_id": { "type": "string", "format": "uuid" }, "created_at": { "type": "string", "format": "date-time" } }, "required": ["id", "slug", "name", "plan", "owner_user_id", "created_at"] } }, "active_workspace_id": { "type": ["string", "null"], "format": "uuid" }, "active_workspace_role": { "type": ["string", "null"], "enum": ["viewer", "member", "admin", "owner", null] }, "auth": { "type": "object", "properties": { "source": { "type": "string", "enum": ["cookie", "bearer"] }, "scopes": { "type": "array", "items": { "type": "string" } } }, "required": ["source", "scopes"] } }, "required": ["user", "workspaces", "active_workspace_id", "active_workspace_role", "auth"] }, "MilestoneListResponse": { "type": "object", "properties": { "milestones": { "type": "array", "items": { "$ref": "#/components/schemas/Milestone" }, "description": "Ordered by target_date ascending, id ascending." } }, "required": ["milestones"] }, "Milestone": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "name": { "type": "string", "description": "1–100 chars after normalize (internal whitespace collapsed, then trimmed). Unique per project, case-insensitive." }, "target_date": { "type": "string", "description": "Calendar date (YYYY-MM-DD). Today or later at create; within 5 years of the write date." }, "description": { "type": "string", "description": "0–500 chars." }, "created_by": { "type": ["string", "null"], "format": "uuid" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "project_id", "name", "target_date", "description", "created_by", "created_at" ] }, "MilestoneCreateResponse": { "type": "object", "properties": { "milestone": { "$ref": "#/components/schemas/Milestone" } }, "required": ["milestone"] }, "MilestoneCreateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "1–100 chars after normalize. Unique per project, case-insensitive." }, "target_date": { "type": "string", "description": "Calendar date (YYYY-MM-DD). Must be today or later, and within 5 years of today." }, "description": { "type": "string", "description": "0–500 chars. Defaults to empty." } }, "required": ["name", "target_date"] }, "MilestoneUpdateResponse": { "type": "object", "properties": { "milestone": { "$ref": "#/components/schemas/Milestone" } }, "required": ["milestone"] }, "MilestoneUpdateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "1–100 chars after normalize. Unique per project, case-insensitive." }, "target_date": { "type": "string", "description": "Calendar date (YYYY-MM-DD). Bounds (today-or-later, within 5 years) apply ONLY when this differs from the milestone's current stored value." }, "description": { "type": "string", "description": "0–500 chars. DEFAULTS TO EMPTY WHEN OMITTED, and the empty value is written straight through — omitting this field CLEARS an existing description with no warning and no way to recover it. Always resend the current description on an edit that is not meant to erase it." } }, "required": ["name", "target_date"] }, "ModuleUpdateResponse": { "type": "object", "properties": { "module": { "$ref": "#/components/schemas/ModuleDetail" } }, "required": ["module"] }, "ModuleDetail": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "parent_module_id": { "type": ["string", "null"], "format": "uuid" }, "path": { "type": "string" }, "name": { "type": "string" }, "position": { "type": "integer" }, "description": { "type": ["string", "null"] }, "created_at": { "type": "string", "format": "date-time" }, "archived_at": { "type": ["string", "null"], "format": "date-time" } }, "required": [ "id", "project_id", "parent_module_id", "path", "name", "position", "description", "created_at", "archived_at" ] }, "ModuleUpdateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "New name, 2–80 chars, at least one alphanumeric. Renaming rebuilds the module path and every descendant path. Mutually exclusive with `parent_module_id` — a rename cannot be combined with a move." }, "description": { "type": ["string", "null"], "description": "New description (max 500 chars). Pass null to clear it; omit to leave it unchanged. Mutually exclusive with `parent_module_id` — a description edit cannot be combined with a move." }, "parent_module_id": { "type": ["string", "null"], "format": "uuid", "description": "Move the module: a UUID re-parents it under that module (carrying its subtree); null moves it to the project root; omit to leave the parent unchanged. Rejects cycles, depth > 6, and cross-project targets. Mutually exclusive with `name`/`description` — combining a move with a rename/description edit returns 422 (`combined_update_and_move`)." } } }, "ModuleArchiveResponse": { "type": "object", "properties": { "archived": { "type": "object", "properties": { "modules": { "type": "integer" }, "user_stories": { "type": "integer" }, "acceptance_criteria": { "type": "integer" }, "atcs": { "type": "integer" } }, "required": ["modules", "user_stories", "acceptance_criteria", "atcs"], "description": "Per-table counts of rows archived by the cascade." } }, "required": ["archived"] }, "UserStory": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "module_id": { "type": "string", "format": "uuid" }, "project_id": { "type": ["string", "null"], "format": "uuid" }, "title": { "type": "string" }, "description": { "type": ["string", "null"] }, "external_id": { "type": ["string", "null"] }, "external_url": { "type": ["string", "null"] }, "status": { "type": "string", "enum": ["draft", "ready_to_test"], "description": "Ready-to-test gate (BK-15)." }, "created_at": { "type": "string", "format": "date-time" }, "archived_at": { "type": ["string", "null"], "format": "date-time" } }, "required": [ "id", "module_id", "project_id", "title", "description", "external_id", "external_url", "status", "created_at", "archived_at" ] }, "UserStoryCreateBody": { "type": "object", "properties": { "title": { "type": "string", "description": "3–200 chars." }, "description": { "type": "string", "description": "Optional Markdown, up to 50 KB. Sanitized on save." }, "external_id": { "type": "string", "description": "Optional Jira key (LETTERS-NUMBER, e.g. BK-42). Unique per project, case-insensitive." } }, "required": ["title"] }, "NotificationPreferencesListResponse": { "type": "object", "properties": { "preferences": { "type": "array", "items": { "type": "object", "properties": { "event_type": { "type": "string" }, "channel": { "type": "string", "enum": ["in_app", "email"] }, "enabled": { "type": "boolean" }, "locked": { "type": "boolean" } }, "required": ["event_type", "channel", "enabled", "locked"] } } }, "required": ["preferences"] }, "NotificationPreferencePatchResponse": { "type": "object", "properties": { "preference": { "type": "object", "properties": { "event_type": { "type": "string" }, "channel": { "type": "string", "enum": ["in_app", "email"] }, "enabled": { "type": "boolean" }, "updated_at": { "type": "string", "format": "date-time" } }, "required": ["event_type", "channel", "enabled", "updated_at"] } }, "required": ["preference"] }, "NotificationPreferencePatchBody": { "type": "object", "properties": { "event_type": { "type": "string", "enum": ["run_lifecycle", "bug_lifecycle"] }, "channel": { "type": "string", "enum": ["in_app", "email"] }, "enabled": { "type": "boolean" } }, "required": ["event_type", "channel", "enabled"] }, "MarkNotificationReadResponse": { "type": "object", "properties": { "notification": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "read_at": { "type": ["string", "null"], "format": "date-time" } }, "required": ["id", "read_at"] } }, "required": ["notification"] }, "DefectHeatmapReport": { "type": "object", "properties": { "window": { "type": "string", "enum": ["7d", "30d", "90d"] }, "generated_at": { "type": "string", "format": "date-time", "description": "Freshness timestamp for this read — a live, unpaginated query, not a cached snapshot." }, "items": { "type": "array", "items": { "$ref": "#/components/schemas/DefectHeatmapItem" }, "description": "One cell per ACTIVE (non-archived) module, ordered by module path then id." } }, "required": ["window", "generated_at", "items"] }, "DefectHeatmapItem": { "type": "object", "properties": { "module_id": { "type": "string", "format": "uuid" }, "module_name": { "type": "string" }, "module_path": { "type": "string", "description": "Full slash-separated module path, so identically-named nested modules stay distinguishable." }, "defect_count": { "type": "integer", "description": "Defect count for this module (including its full descendant subtree) within the selected window." }, "heat": { "type": "string", "enum": ["clean", "low", "elevated", "hotspot"], "description": "Clean: 0. Low: 1-2. Elevated: 3-4. Hotspot: 5+ (master-design-plan §4.6)." }, "current_week_count": { "type": "integer", "description": "Defect count in the latest rolling 7-day UTC bucket (independent of the selected window)." }, "previous_week_count": { "type": "integer", "description": "Defect count in the immediately preceding 7-day UTC bucket." }, "trend_direction": { "type": "string", "enum": ["rising", "falling", "flat"] }, "trend_delta": { "type": "integer", "description": "current_week_count - previous_week_count. Always present, always finite." }, "trend_pct": { "type": ["number", "null"], "description": "Signed percent change. Null exactly when previous_week_count is 0 and current_week_count is positive (never Infinity) — a 0/0 pair reads 0, not null." } }, "required": [ "module_id", "module_name", "module_path", "defect_count", "heat", "current_week_count", "previous_week_count", "trend_direction", "trend_delta", "trend_pct" ] }, "BugListResponse": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Bug" }, "description": "Newest first (created_at desc)." } }, "required": ["items"] }, "CoveragePayload": { "type": "object", "properties": { "kpis": { "$ref": "#/components/schemas/CoverageKpis" }, "modules": { "type": "array", "items": { "$ref": "#/components/schemas/CoverageModule" }, "description": "Every non-archived Module of the Project, ordered by position — including modules with zero ACs (`status: \"no_acs\"`)." }, "no_coverage": { "type": "array", "items": { "$ref": "#/components/schemas/CoverageNoCoverageItem" }, "description": "Every `uncovered` AC, itemized. The client collapses an entirely-unbound module's ACs into one summary row (lib/coverage/coverage-view.ts)." } }, "required": ["kpis", "modules", "no_coverage"] }, "CoverageKpis": { "type": "object", "properties": { "ac_total": { "type": "integer" }, "ac_bound": { "type": "integer", "description": "ac_total minus the uncovered count — ACs with at least one linked ATC, run or not." }, "ac_executed": { "type": "integer" }, "modules_total": { "type": "integer" }, "modules_fully_covered": { "type": "integer" } }, "required": [ "ac_total", "ac_bound", "ac_executed", "modules_total", "modules_fully_covered" ] }, "CoverageModule": { "type": "object", "properties": { "module_id": { "type": "string", "format": "uuid" }, "module_name": { "type": "string" }, "ac_total": { "type": "integer" }, "ac_uncovered": { "type": "integer" }, "ac_not_run": { "type": "integer" }, "ac_executed": { "type": "integer" }, "status": { "type": "string", "enum": ["uncovered", "not_run", "fully_covered", "no_acs"], "description": "uncovered: at least one AC has zero linked ATCs. not_run: zero uncovered ACs, at least one AC is not_run. fully_covered: every AC is executed. no_acs: the module has zero User Stories/Acceptance Criteria." } }, "required": [ "module_id", "module_name", "ac_total", "ac_uncovered", "ac_not_run", "ac_executed", "status" ] }, "CoverageNoCoverageItem": { "type": "object", "properties": { "ac_id": { "type": "string", "format": "uuid" }, "ac_title": { "type": "string" }, "user_story_id": { "type": "string", "format": "uuid" }, "user_story_title": { "type": "string" }, "module_id": { "type": "string", "format": "uuid" }, "module_name": { "type": "string" } }, "required": [ "ac_id", "ac_title", "user_story_id", "user_story_title", "module_id", "module_name" ] }, "ProjectEnvironmentListResponse": { "type": "object", "properties": { "environments": { "type": "array", "items": { "$ref": "#/components/schemas/ProjectEnvironment" }, "description": "Ordered by name ascending." } }, "required": ["environments"] }, "ProjectEnvironment": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "name": { "type": "string", "description": "1–50 chars after trim, unique per project (case-insensitive)." }, "created_at": { "type": "string", "format": "date-time" } }, "required": ["id", "project_id", "name", "created_at"] }, "ProjectEnvironmentCreateResponse": { "type": "object", "properties": { "environment": { "$ref": "#/components/schemas/ProjectEnvironment" } }, "required": ["environment"] }, "ProjectEnvironmentCreateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "1–50 chars after trim. Unique per project, case-insensitive." } }, "required": ["name"] }, "RecoveryCycleReport": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/RecoveryCycleItem" }, "description": "Every User Story with at least one Run touching it, ordered by module path then story title then id." }, "median_recovery_seconds": { "type": ["integer", "null"], "description": "Median cycle_seconds across every `recovered` item only. Null when resolved_cycle_count is 0 (never a false \"0s\" reading)." }, "resolved_cycle_count": { "type": "integer", "description": "Count of items with state `recovered` — the population median_recovery_seconds is computed over." }, "story_count": { "type": "integer", "description": "Total items returned (all three states combined)." } }, "required": ["items", "median_recovery_seconds", "resolved_cycle_count", "story_count"] }, "RecoveryCycleItem": { "type": "object", "properties": { "user_story_id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "external_id": { "type": ["string", "null"], "description": "The User Story's optional external tracker key (e.g. a Jira key). Null when not set — never a fabricated code." }, "module_id": { "type": "string", "format": "uuid" }, "module_path": { "type": "string" }, "first_fail_at": { "type": ["string", "null"], "format": "date-time", "description": "The first failing terminal Run's finished_at. Null only for `no_cycle` (never failed)." }, "first_green_at": { "type": ["string", "null"], "format": "date-time", "description": "The first qualifying all-passing terminal Run's finished_at that occurred after first_fail_at. Null unless state is `recovered`." }, "state": { "type": "string", "enum": ["recovered", "in_progress", "no_cycle"], "description": "recovered: a qualifying green Run followed the first fail. in_progress: still failing, no qualifying green Run yet. no_cycle: the story has never failed." }, "cycle_seconds": { "type": ["integer", "null"], "description": "recovered: resolved fail->green duration, in seconds. in_progress: elapsed-so-far duration against this response's generation time, in seconds. no_cycle: null." } }, "required": [ "user_story_id", "title", "external_id", "module_id", "module_path", "first_fail_at", "first_green_at", "state", "cycle_seconds" ] }, "ModuleCreateResponse": { "type": "object", "properties": { "module": { "$ref": "#/components/schemas/Module" }, "warning": { "type": "string", "description": "Present only when the resulting nesting depth is >= 5." } }, "required": ["module"] }, "Module": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "parent_module_id": { "type": ["string", "null"], "format": "uuid" }, "path": { "type": "string" }, "name": { "type": "string" }, "position": { "type": "integer" }, "description": { "type": ["string", "null"] }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "project_id", "parent_module_id", "path", "name", "position", "description", "created_at" ] }, "ModuleCreateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "2–80 chars, at least one alphanumeric. Path segment is auto-derived." }, "description": { "type": "string", "description": "Optional Markdown, max 500 chars." }, "parent_module_id": { "type": "string", "format": "uuid", "description": "Parent module id; omit for a root module. Must belong to the same project." } }, "required": ["name"] }, "RunReportPage": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/RunReportItem" }, "description": "The page, ordered newest first (started_at desc, id desc)." }, "totals": { "$ref": "#/components/schemas/RunReportTotals" }, "next_cursor": { "type": ["string", "null"], "description": "Opaque token for the next (older) page, or null when this is the last page. base64url, so it is URL-safe as-is: echo it back verbatim as `?cursor=`; never construct or parse one." } }, "required": ["items", "totals", "next_cursor"] }, "RunReportItem": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "test_id": { "type": "string", "format": "uuid" }, "test_title": { "type": "string" }, "module_id": { "type": ["string", "null"], "format": "uuid", "description": "The module snapshotted at Run start (chain-position-1 ATC's module). Null when that ATC was later deleted (Risk R-3)." }, "module_name": { "type": ["string", "null"], "description": "Null whenever module_id is null." }, "environment_id": { "type": "string", "format": "uuid" }, "environment_name": { "type": ["string", "null"], "description": "The project environment the Run targeted." }, "executor_mode": { "type": "string", "enum": ["human", "agent", "ci"] }, "status": { "type": "string", "enum": ["running", "passed", "failed", "aborted"], "description": "The Run's current status, INCLUDING `running` — this is a live report, not a terminal-only history. `running` cannot be used as a `status` filter value (see the query parameter), it can only appear as a row." }, "started_at": { "type": "string", "format": "date-time" }, "finished_at": { "type": ["string", "null"], "format": "date-time" } }, "required": [ "id", "test_id", "test_title", "module_id", "module_name", "environment_id", "environment_name", "executor_mode", "status", "started_at", "finished_at" ] }, "RunReportTotals": { "type": "object", "properties": { "passed": { "type": "integer" }, "failed": { "type": "integer" } }, "required": ["passed", "failed"], "description": "Counts over the WHOLE filtered set (every row matching the active filters, not just the returned page) — Business Rule #3 / Technical Decision D2. Deliberately the OPPOSITE of RunHistoryTotals: these numbers change as filters narrow. `running` and `aborted` Runs are not counted here (aborted is a terminal outcome but not a pass/fail verdict)." }, "TestPlanListResponse": { "type": "object", "properties": { "test_plans": { "type": "array", "items": { "$ref": "#/components/schemas/TestPlan" }, "description": "Ordered by created_at descending, id descending." } }, "required": ["test_plans"] }, "TestPlan": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "name": { "type": "string", "description": "1–100 chars after normalize (internal whitespace collapsed, then trimmed). Unique per project, case-insensitive." }, "description": { "type": "string", "description": "0–500 chars." }, "goal": { "type": "string", "description": "0–100 chars after normalize. Optional short target/release label, e.g. \"Release 2.4\"." }, "status": { "type": "string", "enum": ["open", "closed"], "description": "A new plan is always \"open\". Closing is a separate capability and is not exposed by this API." }, "created_by": { "type": ["string", "null"], "format": "uuid" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "project_id", "name", "description", "goal", "status", "created_by", "created_at" ] }, "TestPlanCreateResponse": { "type": "object", "properties": { "test_plan": { "$ref": "#/components/schemas/TestPlan" } }, "required": ["test_plan"] }, "TestPlanCreateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "1–100 chars after normalize. Unique per project, case-insensitive." }, "description": { "type": "string", "description": "0–500 chars. Defaults to empty." }, "goal": { "type": "string", "description": "0–100 chars after normalize. Defaults to empty." } }, "required": ["name"] }, "StoryTraceabilityPayload": { "type": "object", "properties": { "story": { "$ref": "#/components/schemas/TraceabilityStory" }, "criteria": { "type": "array", "items": { "$ref": "#/components/schemas/TraceabilityCriterion" }, "description": "Every non-archived acceptance criterion of this User Story, ordered by position. Empty when the story has no acceptance criteria at all." } }, "required": ["story", "criteria"] }, "TraceabilityStory": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "status": { "type": "string", "enum": ["draft", "ready_to_test"] }, "archived_at": { "type": ["string", "null"], "format": "date-time" } }, "required": ["id", "title", "status", "archived_at"] }, "TraceabilityCriterion": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "atcs": { "type": "array", "items": { "$ref": "#/components/schemas/TraceabilityAtc" }, "description": "Empty when this acceptance criterion has zero ATCs bound (renders the \"Uncovered\" strip)." } }, "required": ["id", "title", "atcs"] }, "TraceabilityAtc": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string" }, "title": { "type": "string" }, "layer": { "type": "string", "enum": ["UI", "API", "Unit"] }, "module": { "$ref": "#/components/schemas/TraceabilityModule" }, "test": { "$ref": "#/components/schemas/TraceabilityTest" }, "latest_run": { "$ref": "#/components/schemas/TraceabilityLatestRun" }, "defects": { "type": "array", "items": { "$ref": "#/components/schemas/TraceabilityDefect" }, "description": "Every defect whose provenance resolves to this ATC, not only the latest run's (\"None linked\" when empty)." } }, "required": ["id", "slug", "title", "layer", "module", "test", "latest_run", "defects"] }, "TraceabilityModule": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" } }, "required": ["id", "name"], "description": "The Module this ATC belongs to (BK-48 filter target). Always present — every ATC has a module_id." }, "TraceabilityTest": { "type": ["object", "null"], "properties": { "id": { "type": "string", "format": "uuid" }, "title": { "type": "string" } }, "required": ["id", "title"], "description": "Null when no Test chains this ATC yet (\"No test written yet\")." }, "TraceabilityLatestRun": { "type": ["object", "null"], "properties": { "run_id": { "type": "string", "format": "uuid" }, "run_status": { "type": "string", "enum": ["running", "passed", "failed", "aborted"] }, "atc_status": { "type": "string", "enum": ["pending", "passed", "failed", "blocked", "skipped"] }, "started_at": { "type": "string", "format": "date-time" }, "finished_at": { "type": ["string", "null"], "format": "date-time" }, "state": { "type": "string", "enum": ["in_flight", "aborted", "passed", "failed", "blocked", "skipped"], "description": "Run-level `running` outranks any position verdict — an ATC inside a still-running Run always reads `in_flight`, never a stale prior verdict." } }, "required": ["run_id", "run_status", "atc_status", "started_at", "finished_at", "state"], "description": "Null when a Test chains this ATC but it has never been run (\"No run recorded yet\")." }, "TraceabilityDefect": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "severity": { "type": "string", "enum": ["P1", "P2", "P3", "P4"] }, "status": { "type": "string", "enum": ["open", "in_progress", "resolved", "closed"] }, "created_at": { "type": "string", "format": "date-time" }, "run_id": { "type": ["string", "null"], "format": "uuid" }, "run_step_id": { "type": ["string", "null"], "format": "uuid" } }, "required": ["id", "title", "severity", "status", "created_at", "run_id", "run_step_id"] }, "ApiVersionResponse": { "type": "object", "properties": { "version": { "type": "string", "enum": ["v1"] }, "openapi": { "type": "string", "example": "/api/openapi" }, "docs": { "type": "string", "example": "/api/docs" }, "status": { "type": "string", "enum": ["live"] } }, "required": ["version", "openapi", "docs", "status"] }, "Run": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "workspace_id": { "type": "string", "format": "uuid" }, "project_id": { "type": "string", "format": "uuid" }, "test_id": { "type": "string", "format": "uuid" }, "environment_id": { "type": "string", "format": "uuid" }, "environment_name": { "type": ["string", "null"] }, "status": { "type": "string", "enum": ["running", "passed", "failed", "aborted"] }, "abort_reason": { "type": ["string", "null"], "description": "BK-36 — the reason captured when the run was aborted; null for non-aborted runs." }, "executor_mode": { "type": "string", "enum": ["human", "agent", "ci"] }, "executor_user_id": { "type": ["string", "null"], "format": "uuid" }, "test_title": { "type": "string", "description": "Snapshot of the Test title at start." }, "version": { "type": "integer" }, "started_at": { "type": "string", "format": "date-time" }, "finished_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "atc_count": { "type": "integer" }, "step_count": { "type": "integer" }, "atcs": { "type": "array", "items": { "$ref": "#/components/schemas/RunAtc" }, "description": "The snapshot chain, ordered by position." }, "replayed": { "type": "boolean", "description": "true when this call returned an existing Run within the 24h same-token window (HTTP 200); false/absent when freshly created (HTTP 201)." } }, "required": [ "id", "workspace_id", "project_id", "test_id", "environment_id", "environment_name", "status", "abort_reason", "executor_mode", "executor_user_id", "test_title", "version", "started_at", "finished_at", "created_at", "updated_at", "atc_count", "step_count", "atcs" ] }, "RunAtc": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "atc_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source ATC; SET NULL if the source is deleted." }, "position": { "type": "integer", "description": "Chain position copied from test_steps.position (1..n)." }, "atc_title": { "type": "string", "description": "Snapshot of the ATC title at start." }, "status": { "type": "string", "enum": ["pending", "passed", "failed", "blocked", "skipped"] }, "steps": { "type": "array", "items": { "$ref": "#/components/schemas/RunStep" } } }, "required": ["id", "atc_id", "position", "atc_title", "status", "steps"] }, "RunStep": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "atc_step_id": { "type": ["string", "null"], "format": "uuid", "description": "Provenance link to the source atc_steps row; SET NULL if the source is deleted. Never read for content." }, "position": { "type": "integer" }, "content": { "type": "string", "description": "Snapshot of atc_steps.content at start — frozen; later Test edits never alter it." }, "input_data": { "type": ["string", "null"] }, "expected": { "type": ["string", "null"] }, "status": { "type": "string", "enum": ["pending", "passed", "failed", "blocked", "skipped"] }, "note": { "type": ["string", "null"] }, "evidence_url": { "type": ["string", "null"] }, "executed_at": { "type": ["string", "null"], "format": "date-time" } }, "required": [ "id", "atc_step_id", "position", "content", "input_data", "expected", "status", "note", "evidence_url", "executed_at" ] }, "RunCreateBody": { "type": "object", "properties": { "test_id": { "type": "string", "format": "uuid", "description": "The Test to run." }, "environment_id": { "type": "string", "format": "uuid", "description": "A configured project_environments row belonging to the Test's Project." }, "executor_mode": { "type": "string", "enum": ["human", "agent", "ci"], "description": "Cookie sessions are always `human` (any supplied value is ignored). Bearer (PAT) callers may declare `agent` / `ci`; defaults to `human`." }, "start_token": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Domain idempotency token (distinct from the Idempotency-Key header). A same (test_id, start_token) within 24h returns the existing Run (HTTP 200). Omit it to mint a fresh one server-side (always a new Run)." } }, "required": ["test_id", "environment_id"] }, "RunAbortBody": { "type": "object", "properties": { "reason": { "type": "string", "minLength": 3, "maxLength": 500, "description": "Why the run is being aborted. Trimmed, then required to be 3..500 characters." } }, "required": ["reason"] }, "RunFinishBody": { "type": "object", "properties": { "verdict": { "type": "string", "enum": ["passed", "failed"], "description": "The final verdict to close the run with. `aborted` is not a finish verdict — abort is its own action (BK-36)." } }, "required": ["verdict"] }, "RunStepMarkBody": { "type": "object", "properties": { "status": { "type": "string", "enum": ["passed", "failed", "blocked"], "description": "The step result. `pending` is never accepted — a re-mark-to-pending attempt is rejected as validation_failed." }, "note": { "type": ["string", "null"], "maxLength": 2000, "description": "Optional free-text note. An empty or whitespace-only string normalizes to null rather than being rejected." }, "evidence_url": { "type": ["string", "null"], "maxLength": 2000, "format": "uri", "description": "Optional evidence link. An empty or whitespace-only string normalizes to null rather than being rejected; a non-empty value must be an http:// or https:// URL — any other scheme (javascript:, data:, etc.) is rejected (BK-466)." } }, "required": ["status"] }, "SearchPageResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/SearchResultItem" }, "description": "Matches across all six entity types. The RPC caps each GROUP at 5 rows regardless of `limit`, so a broad query returns at most 5 ATCs, 5 Tests, 5 Projects, and so on." }, "truncated": { "type": "boolean", "description": "True when ANY entity group hit its 5-row cap — the signal for rendering a per-group \"+ more\" hint. It does not mean the whole result set was cut to `limit`." } }, "required": ["data", "truncated"] }, "SearchResultItem": { "type": "object", "properties": { "entity_type": { "type": "string", "enum": ["atc", "test", "project", "module", "bug", "run"] }, "id": { "type": "string", "format": "uuid" }, "name": { "type": "string", "description": "Display label for the row — the entity's title / name / slug as the RPC projected it." }, "project_id": { "type": "string", "format": "uuid" }, "project_slug": { "type": "string" }, "project_name": { "type": "string" }, "href": { "type": "string", "description": "Server-built destination path for this row (e.g. `/projects/{slug}/atcs/{id}`, `/projects/{slug}?module={id}`). Navigate to it verbatim — never reconstruct it client-side." } }, "required": [ "entity_type", "id", "name", "project_id", "project_slug", "project_name", "href" ] }, "TestPlanUpdateResponse": { "type": "object", "properties": { "test_plan": { "$ref": "#/components/schemas/TestPlan" } }, "required": ["test_plan"] }, "TestPlanUpdateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "1–100 chars after normalize. Unique per project, case-insensitive — re-validated on rename by the same database index that guards create." }, "description": { "type": "string", "description": "0–500 chars. Defaults to empty." }, "goal": { "type": "string", "description": "0–100 chars after normalize. Defaults to empty." } }, "required": ["name"] }, "TestPlanRemoveTestResponse": { "type": "object", "properties": { "test_plan_id": { "type": "string", "format": "uuid" }, "removed_test_id": { "type": "string", "format": "uuid" }, "member_count": { "type": "integer", "description": "Total member count after this removal." } }, "required": ["test_plan_id", "removed_test_id", "member_count"] }, "TestPlanMemberTestListResponse": { "type": "object", "properties": { "tests": { "type": "array", "items": { "$ref": "#/components/schemas/TestPlanMemberTest" }, "description": "Ordered by added_at ascending (addition order)." }, "count": { "type": "integer" } }, "required": ["tests", "count"] }, "TestPlanMemberTest": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "The Test id (not the membership row id)." }, "title": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "added_by": { "type": ["string", "null"], "format": "uuid" }, "added_by_email": { "type": ["string", "null"] }, "added_at": { "type": "string", "format": "date-time" } }, "required": ["id", "title", "tags", "added_by", "added_by_email", "added_at"] }, "TestPlanAddTestsResponse": { "type": "object", "properties": { "test_plan_id": { "type": "string", "format": "uuid" }, "added_count": { "type": "integer" }, "member_count": { "type": "integer", "description": "Total member count after this add." } }, "required": ["test_plan_id", "added_count", "member_count"] }, "TestPlanAddTestsBody": { "type": "object", "properties": { "test_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1, "description": "At least one Test id from the plan's own project." } }, "required": ["test_ids"] }, "TestReorderBody": { "type": "object", "properties": { "step_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "description": "The COMPLETE new order as test_steps.id values — a permutation of the Test's current steps. step_id (not atc_id) is the handle because a chain may repeat an atc_id. Non-empty and free of duplicates." } }, "required": ["step_ids"] }, "ExpandedTest": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "workspace_id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" }, "updated_at": { "type": "string", "format": "date-time" }, "atc_count": { "type": "integer", "description": "Number of ATCs in the chain (= count of `test_steps`)." }, "atcs": { "type": "array", "items": { "$ref": "#/components/schemas/ChainedAtc" }, "description": "The chain of ATCs, ordered by `test_steps.position`." } }, "required": ["id", "workspace_id", "title", "created_at", "updated_at", "atc_count", "atcs"] }, "ChainedAtc": { "type": "object", "properties": { "position": { "type": "integer", "description": "Position of this ATC in the Test chain (1..n, from `test_steps.position`)." }, "step_id": { "type": "string", "format": "uuid", "description": "`test_steps.id` — the stable chain-row handle." }, "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string" }, "title": { "type": "string" }, "layer": { "type": "string", "enum": ["UI", "API", "Unit"] }, "status": { "type": "string" }, "steps": { "type": "array", "items": { "$ref": "#/components/schemas/ChainedStep" } }, "assertions": { "type": "array", "items": { "$ref": "#/components/schemas/ChainedAssertion" } } }, "required": [ "position", "step_id", "id", "slug", "title", "layer", "status", "steps", "assertions" ] }, "ChainedStep": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "position": { "type": "integer" }, "content": { "type": "string" }, "input_data": { "type": ["string", "null"] }, "expected": { "type": ["string", "null"] } }, "required": ["id", "position", "content", "input_data", "expected"] }, "ChainedAssertion": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "position": { "type": "integer" }, "content": { "type": "string" } }, "required": ["id", "position", "content"] }, "RunHistoryPage": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/RunHistoryItem" }, "description": "The page, ordered newest first (started_at desc, id desc)." }, "totals": { "$ref": "#/components/schemas/RunHistoryTotals" }, "next_cursor": { "type": ["string", "null"], "description": "Opaque token for the next (older) page, or null when this is the last page. base64url, so it is URL-safe as-is: echo it back verbatim as `?cursor=`; never construct or parse one." } }, "required": ["items", "totals", "next_cursor"] }, "RunHistoryItem": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "status": { "type": "string", "enum": ["passed", "failed", "aborted"], "description": "Terminal outcome. `running` never appears — an in-progress Run is not a past run." }, "environment_id": { "type": "string", "format": "uuid" }, "environment_name": { "type": ["string", "null"], "description": "The project environment the Run targeted." }, "executor_mode": { "type": "string", "enum": ["human", "agent", "ci"] }, "started_at": { "type": "string", "format": "date-time" }, "finished_at": { "type": ["string", "null"], "format": "date-time" } }, "required": [ "id", "status", "environment_id", "environment_name", "executor_mode", "started_at", "finished_at" ] }, "RunHistoryTotals": { "type": "object", "properties": { "passed": { "type": "integer" }, "failed": { "type": "integer" }, "aborted": { "type": "integer" } }, "required": ["passed", "failed", "aborted"], "description": "ALL-TIME counts over every terminal Run of the Test. Deliberately invariant under both `outcome` and pagination — filtering the list never changes these numbers." }, "TestTagsBody": { "type": "object", "properties": { "tags": { "type": "array", "items": { "type": "string", "maxLength": 50 }, "maxItems": 20, "description": "The COMPLETE new tag set — assigning replaces the whole set; an empty array clears all tags. Reserved tags (smoke/sanity/regression) are lowercased; custom tags preserve casing. Server normalizes (trim, dedupe) and rejects commas, >50 chars, or >20 tags." } }, "required": ["tags"] }, "Test": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "workspace_id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "created_by": { "type": "string", "format": "uuid" }, "created_at": { "type": "string", "format": "date-time" }, "steps": { "type": "array", "items": { "$ref": "#/components/schemas/TestStep" } } }, "required": ["id", "workspace_id", "title", "created_by", "created_at", "steps"] }, "TestStep": { "type": "object", "properties": { "position": { "type": "integer", "description": "1-based position in the chain; follows `atc_ids` array order verbatim." }, "atc_id": { "type": "string", "format": "uuid", "description": "Chained ATC reference — a reference, never a copy." } }, "required": ["position", "atc_id"] }, "TestCreateBody": { "type": "object", "properties": { "title": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Trimmed before validation; 1–200 chars after trim." }, "atc_ids": { "type": "array", "items": { "type": "string", "format": "uuid" }, "minItems": 1, "description": "Ordered chain, ≥1. Duplicates are legal — a chain is a sequence, not a set. No server-side length cap." }, "workspace_id": { "type": "string", "format": "uuid", "description": "Required for token-authenticated (Bearer) callers. Cookie sessions default to the active workspace at the submit instant." } }, "required": ["title", "atc_ids"] }, "FilteredTest": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "step_count": { "type": "integer" } }, "required": ["id", "title", "tags", "step_count"] }, "TestSearchResult": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "title": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } } }, "required": ["id", "title", "tags"] }, "CreateTokenResponse": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "token": { "type": "string", "description": "Raw PAT in the form `bk_pat_.`. Shown ONCE in this response. The server only stores SHA-256(secret).", "example": "bk_pat_AbCdEfGhIjKl.MnOpQrStUv..." }, "name": { "type": ["string", "null"] }, "scopes": { "type": "array", "items": { "type": "string", "enum": ["atc:read", "atc:write", "run:execute", "workspace:admin"], "description": "PAT scope vocabulary. Mirrors `.context/SRS/api-contracts.yaml` tags." } }, "workspace_id": { "type": ["string", "null"], "format": "uuid" }, "expires_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" }, "warning": { "type": "string", "example": "Store this token now — it cannot be retrieved later." } }, "required": [ "id", "token", "name", "scopes", "workspace_id", "expires_at", "created_at", "warning" ] }, "CreateTokenBody": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 80, "description": "Human-readable label shown in the token-management UI.", "example": "ci-pipeline" }, "scopes": { "type": "array", "items": { "type": "string", "enum": ["atc:read", "atc:write", "run:execute", "workspace:admin"], "description": "PAT scope vocabulary. Mirrors `.context/SRS/api-contracts.yaml` tags." }, "minItems": 1, "description": "Scopes granted to this token. At least one required. Requesting `workspace:admin` makes `workspace_id` MANDATORY — omitting it returns 403 `forbidden` — and additionally requires the caller to hold the role `admin` or `owner` in that workspace; any other role returns 403. Both guards run after schema validation, so the enum accepting the value is not a grant. See ADR-0005." }, "workspace_id": { "type": "string", "format": "uuid", "description": "Restrict the token to a single workspace. Omit for cross-workspace tokens (non-admin scopes only). When supplied, the caller must be an ACTIVE member of that workspace — a non-member or a non-active membership returns 403 `forbidden`." }, "expires_in_days": { "type": "integer", "exclusiveMinimum": 0, "maximum": 365, "description": "TTL in days. Omit for non-expiring tokens. Max 365." } }, "required": ["scopes"] }, "ListTokensResponse": { "type": "object", "properties": { "tokens": { "type": "array", "items": { "$ref": "#/components/schemas/TokenSummary" } } }, "required": ["tokens"] }, "TokenSummary": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": ["string", "null"] }, "scopes": { "type": "array", "items": { "type": "string", "enum": ["atc:read", "atc:write", "run:execute", "workspace:admin"], "description": "PAT scope vocabulary. Mirrors `.context/SRS/api-contracts.yaml` tags." } }, "workspace_id": { "type": ["string", "null"], "format": "uuid" }, "token_prefix": { "type": "string", "description": "First 12 chars of the secret. Safe to display." }, "expires_at": { "type": ["string", "null"], "format": "date-time" }, "revoked_at": { "type": ["string", "null"], "format": "date-time" }, "last_used_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "name", "scopes", "workspace_id", "token_prefix", "expires_at", "revoked_at", "last_used_at", "created_at" ] }, "UserStoryUpdateBody": { "type": "object", "properties": { "title": { "type": "string", "description": "3–200 chars." }, "description": { "type": ["string", "null"], "description": "Markdown, up to 50 KB; null clears it." }, "external_id": { "type": ["string", "null"], "description": "Jira key, e.g. `BK-42`. Immutable once set: after a story carries an `external_id`, ANY differing value returns 409 `conflict` (`details.reason = \"external_id_immutable\"`) — including `null` and an empty string. A set Jira link cannot be cleared or relinked through this endpoint; only re-sending the identical key is accepted, as a no-op. While the story has no link yet, the key must read as LETTERS-NUMBER or the request is 422." }, "status": { "type": "string", "enum": ["draft", "ready_to_test"], "description": "Ready-to-test gate: moving to ready_to_test with zero active acceptance criteria returns 409 (BK-15)." } } }, "ActiveRuns": { "type": "object", "properties": { "active_count": { "type": "integer", "description": "How many runs are in progress across the WHOLE workspace. Exact, and deliberately NOT capped by `limit` — a workspace with eight running runs reports 8 while returning at most `limit` rows. This is the same predicate (`runs.status = 'running'`) the Home welcome banner counts, so the two numbers on that screen agree by construction." }, "runs": { "type": "array", "items": { "$ref": "#/components/schemas/ActiveRun" }, "description": "Ordered by `last_activity_at` descending (id descending as the tie-break), so the first entry is the run to resume. At most `limit` entries. NOTE the two-stage boundary: `started_at` descending decides WHICH runs make the page (that is what the database can order cheaply), and `last_activity_at` orders the page. A run old enough to fall outside `limit` by start date is not pulled back in by recent step activity. When `active_count` exceeds this array's length the remainder is NOT reachable from here — there is no cursor and no workspace-wide runs index; read the missing runs from each project's own run report." } }, "required": ["active_count", "runs"] }, "ActiveRun": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "The run's id. The run opens at `/projects/{project_slug}/runs/{id}`." }, "project_slug": { "type": "string", "description": "URL slug of the run's project, unique per workspace." }, "project_name": { "type": "string" }, "test_title": { "type": "string", "description": "The Test title as snapshotted when the run started, not as it reads today." }, "executor_mode": { "type": "string", "enum": ["human", "agent", "ci"], "description": "How the run is being executed (`runs.executor_mode`)." }, "executor": { "type": "string", "description": "Who started the run. Falls back to the neutral copy \"a workspace member\" when the executor cannot be resolved (departed member, or no executor on the row) — never a raw user id." }, "state": { "type": "string", "enum": ["running", "blocked"], "description": "DERIVED, not a column: `blocked` when at least one of the run's steps is blocked, `running` otherwise. Both are active — `runs.status` is `running` for every row this endpoint returns, since a blocked run has not terminated. It is a display sub-state, so it never changes `active_count`." }, "total_steps": { "type": "integer", "description": "Steps snapshotted into the run at start. An exact count, not a scan-derived floor." }, "done_steps": { "type": "integer", "description": "Steps that are no longer `pending` — passed, failed, blocked or skipped. Exact." }, "blocked_steps": { "type": "integer" }, "failed_steps": { "type": "integer", "description": "Failed steps do NOT end a run: it stays active until it is explicitly finished or aborted." }, "started_at": { "type": "string", "format": "date-time" }, "last_activity_at": { "type": "string", "format": "date-time", "description": "When the run was last worked on: the newest `executed_at` among its steps, falling back to `started_at` when no step has been marked yet. This is the ordering key. It is NOT `runs.updated_at` — marking a step locks that row but never updates it, so that column is frozen at the run's start for a run in flight. `last_activity_at == started_at` therefore means \"nothing has happened since this run began\"." } }, "required": [ "id", "project_slug", "project_name", "test_title", "executor_mode", "executor", "state", "total_steps", "done_steps", "blocked_steps", "failed_steps", "started_at", "last_activity_at" ] }, "BillingCheckoutResponse": { "type": "object", "properties": { "url": { "type": "string", "format": "uri", "description": "The Stripe-hosted Checkout URL to redirect the browser to. No card data ever reaches this app (Stripe Checkout, hosted — zero PCI scope)." } }, "required": ["url"] }, "BillingCheckoutBody": { "type": "object", "properties": { "seat_quantity": { "type": "integer", "description": "Minimum is the workspace's current active_seats count (no seat-reduction path exists in this Story); maximum is the Cloud tier's seatLimit (25, see lib/billing/plan-tiers.ts)." } }, "required": ["seat_quantity"] }, "WorkspaceBillingOverview": { "type": "object", "properties": { "plan": { "type": "string", "enum": ["community", "cloud", "enterprise"], "description": "The workspace's `workspaces.plan` literal. Look up the tier ladder (display name, seat/project/retention limits, price) for this key in `lib/billing/plan-tiers.ts` — this endpoint does not repeat it." }, "purchased_seats": { "type": ["integer", "null"], "description": "BK-230 — the Cloud workspace's real purchased seat count (set at checkout completion). `null` for Community/Enterprise, or a Cloud workspace predating this column — `lib/billing/plan-tiers.ts`'s `effectiveSeatLimit()` falls back to the tier's flat `seatLimit` in that case. This is the workspace's ACTUAL seat cap; `PLAN_TIERS.cloud.seatLimit` (25) is only the plan's maximum purchasable quantity." }, "active_seats": { "type": "integer", "description": "Workspace members with `status = 'active'` only. Pending invitations and suspended members never count toward this figure." }, "project_count": { "type": "integer", "description": "Every project in the workspace. `projects` carries no soft-delete column, so this is an exact, unfiltered count." }, "oldest_run_age_days": { "type": ["integer", "null"], "description": "Age in days of the workspace's oldest run (`now() - min(runs.created_at)`), or `null` when the workspace has no runs. This reports how much of the plan's retention WINDOW is in use — nothing in this product prunes runs, so this figure is never a countdown to deletion." } }, "required": [ "plan", "purchased_seats", "active_seats", "project_count", "oldest_run_age_days" ], "description": "A workspace's live billing-relevant counts, admin/owner only." }, "WorkspaceCoverage": { "type": "object", "properties": { "ac_total": { "type": "integer", "description": "Every non-archived acceptance criterion, under a non-archived user story, in a non-archived module, across every project in the workspace. The denominator of both percentages below." }, "ac_bound": { "type": "integer", "description": "How many of `ac_total` have at least one non-archived ATC linked to them, executed or not. Always <= `ac_total`." }, "ac_executed": { "type": "integer", "description": "How many of `ac_bound` are currently verified: at least one ATC is linked AND every linked ATC was actually exercised (`passed` or `failed`) in its most recent run; `pending`, `skipped` and `blocked` do not count as executed. POINT-IN-TIME, NOT CUMULATIVE — this is not a count of criteria that have ever been executed. Each ATC resolves to the status of its most recent run only, and adding an ATC to a newly created run resets it to pending, so this figure legitimately falls when a team opens a regression run and rises again as they work through it. Always <= `ac_bound`." }, "ac_not_run": { "type": "integer", "description": "Bound but not currently verified — `ac_bound - ac_executed`: at least one linked ATC is `pending`, `skipped` or `blocked` in its most recent run (or has never been in a run). Kept as its own figure because \"test cases exist but are not verified right now\" is a different problem from \"no test cases exist\", and a single coverage percentage hides the difference. Note this does NOT mean \"never executed\": an ATC with a long execution history returns to this bucket as soon as it joins a new run." }, "ac_uncovered": { "type": "integer", "description": "Nothing bound at all — `ac_total - ac_bound`. `ac_executed`, `ac_not_run` and `ac_uncovered` are an exhaustive three-way partition of `ac_total` and always sum back to it." }, "ac_coverage_percent": { "type": ["integer", "null"], "description": "`ac_bound / ac_total` as a whole percent, rounded half-up but clamped to 1-99 unless the ratio is exact: 100 only when `ac_bound = ac_total`, 0 only when `ac_bound` is 0, so the figure never reads fully covered while a gap exists or empty while coverage exists. This is the figure the Home Coverage card prints, and it is the same quantity the per-project Metrics screen shows as \"AC coverage\". NULL — never 0 — when `ac_total` is 0, because a workspace with no acceptance criteria has nothing to measure rather than a failing score." }, "executed_coverage_percent": { "type": ["integer", "null"], "description": "`ac_executed / ac_total` as a whole percent, rounded half-up with the same 1-99 clamp as `ac_coverage_percent` (100 only when `ac_executed = ac_total`, 0 only when `ac_executed` is 0) — the stricter reading, counting only acceptance criteria whose coverage is verified right now. Matches the per-project Metrics screen's \"Executed coverage\" tile. Inherits `ac_executed`'s point-in-time semantics, so do NOT treat a drop in this figure as lost coverage: opening a regression run over already-tested criteria lowers it by design. `ac_coverage_percent` is the stable one. NULL when `ac_total` is 0." }, "modules_total": { "type": "integer", "description": "Non-archived modules across every project in the workspace." }, "modules_fully_covered": { "type": "integer", "description": "Modules where every acceptance criterion is currently verified — none uncovered and none awaiting execution. Modules with no acceptance criteria at all are not counted as fully covered." }, "project_count": { "type": "integer", "description": "How many projects were rolled up. Zero for a workspace with no projects, and also for a workspace the caller cannot read — see the non-disclosure note on the operation." } }, "required": [ "ac_total", "ac_bound", "ac_executed", "ac_not_run", "ac_uncovered", "ac_coverage_percent", "executed_coverage_percent", "modules_total", "modules_fully_covered", "project_count" ], "description": "A workspace-wide coverage rollup. THE ROLL-UP RULE: every acceptance criterion in the workspace counts exactly once, whichever project it belongs to (`sum(ac_bound) / sum(ac_total)` over the projects) — NOT the average of the per-project percentages, which would weight a 3-criteria project the same as a 300-criteria one. Both percentages are derived from the counts in the same payload, so a caller can recompute them instead of trusting them." }, "WorkspaceExport": { "type": ["object", "null"], "properties": { "id": { "type": "string", "format": "uuid" }, "status": { "type": "string", "enum": ["queued", "running", "completed", "failed"] }, "archive_bytes": { "type": ["integer", "null"] }, "error_message": { "type": ["string", "null"] }, "started_at": { "type": ["string", "null"], "format": "date-time" }, "completed_at": { "type": ["string", "null"], "format": "date-time" }, "expires_at": { "type": ["string", "null"], "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" } }, "required": [ "id", "status", "archive_bytes", "error_message", "started_at", "completed_at", "expires_at", "created_at" ] }, "WorkspaceInviteResendResponse": { "type": "object", "properties": { "invite": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["viewer", "member", "admin"] }, "expires_at": { "type": "string", "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" } }, "required": ["id", "email", "role", "expires_at", "created_at"] }, "token": { "type": "string" }, "accept_url": { "type": "string", "format": "uri" }, "warning": { "type": "string" } }, "required": ["invite", "token", "accept_url", "warning"] }, "WorkspaceInviteDeleteResponse": { "type": "object", "properties": { "ok": { "type": "boolean", "enum": [true] } }, "required": ["ok"] }, "WorkspaceInviteCreateResponse": { "type": "object", "properties": { "invite": { "$ref": "#/components/schemas/WorkspaceInvite" }, "token": { "type": "string", "description": "Raw token. Returned exactly once." }, "accept_url": { "type": "string", "format": "uri" }, "warning": { "type": "string" } }, "required": ["invite", "token", "accept_url", "warning"] }, "WorkspaceInvite": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "email": { "type": "string", "format": "email" }, "role": { "type": "string", "enum": ["viewer", "member", "admin"] }, "expires_at": { "type": "string", "format": "date-time" }, "created_at": { "type": "string", "format": "date-time" }, "accepted_at": { "type": ["string", "null"], "format": "date-time" }, "revoked_at": { "type": ["string", "null"], "format": "date-time" }, "status": { "type": "string", "enum": ["pending", "accepted", "revoked", "expired"] } }, "required": ["id", "email", "role", "expires_at", "created_at"] }, "WorkspaceInviteCreateBody": { "type": "object", "properties": { "email": { "type": "string", "maxLength": 254, "format": "email" }, "role": { "type": "string", "enum": ["viewer", "member", "admin"], "default": "member" } }, "required": ["email"] }, "WorkspaceInviteListResponse": { "type": "object", "properties": { "invites": { "type": "array", "items": { "$ref": "#/components/schemas/WorkspaceInvite" } } }, "required": ["invites"] }, "WorkspaceLeaveResponse": { "type": "object", "properties": { "newActiveWorkspaceId": { "type": ["string", "null"], "format": "uuid" }, "newActiveWorkspaceName": { "type": ["string", "null"] } }, "required": ["newActiveWorkspaceId", "newActiveWorkspaceName"] }, "MarkAllNotificationsReadResponse": { "type": "object", "properties": { "updated_count": { "type": "integer", "description": "How many previously-unread notifications were just marked read. 0 is a valid, successful result (e.g. a repeat call, or an already-empty inbox) — never an error." } }, "required": ["updated_count"] }, "NotificationsPage": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/NotificationItem" }, "description": "The page, ordered newest first (created_at desc, id desc)." }, "unread_count": { "type": "integer", "description": "Count of unread notifications visible to the caller in this workspace — independent of pagination, NOT capped here (the UI applies the \"99+\" display cap, business-rules.md)." }, "next_cursor": { "type": ["string", "null"], "description": "Opaque token for the next (older) page, or null when this is the last page. base64url — echo it back verbatim as `?cursor=`; never construct or parse one." } }, "required": ["items", "unread_count", "next_cursor"] }, "NotificationItem": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid", "description": "notifications row id — stable React key." }, "workspace_id": { "type": "string", "format": "uuid" }, "event_type": { "type": "string", "description": "Producer-defined event key (e.g. a future \"run.finished\"). Open text — no fixed enum yet in this slice." }, "entity_type": { "type": "string", "description": "The kind of entity this notification is about (e.g. \"run\", \"test\", \"bug\")." }, "entity_id": { "type": ["string", "null"], "format": "uuid", "description": "The target entity id, or null when the notification has no single target." }, "payload": { "type": "object", "additionalProperties": {}, "description": "Producer-defined snapshot captured at write time — shape is not yet fixed (no producer story lands in this slice)." }, "read_at": { "type": ["string", "null"], "format": "date-time", "description": "null = unread. Set once, via mark-one-read or mark-all-read." }, "created_at": { "type": "string", "format": "date-time" }, "entity_available": { "type": "boolean", "description": "false when the target entity was deleted, or is no longer visible to the caller (e.g. lost workspace/project access) — the client shows \"This item is no longer available.\" instead of navigating. Always false for entity_type \"bug\" in this slice (no bug detail route exists yet)." } }, "required": [ "id", "workspace_id", "event_type", "entity_type", "entity_id", "payload", "read_at", "created_at", "entity_available" ] }, "OpenBugs": { "type": "object", "properties": { "open_count": { "type": "integer", "description": "How many bugs are OPEN across the WHOLE workspace, where open means UNRESOLVED: `bugs.status` is one of the values in `open_statuses` (`open` or `in_progress`). Both are pre-resolution states — a defect somebody is actively fixing is still outstanding — so triaging a bug from `open` to `in_progress` deliberately does NOT decrease this number; only resolving or closing it does. `resolved` and `closed` are both excluded. Bugs whose own module is ARCHIVED are excluded too, matching every surface that lists bugs (`GET /api/v1/bugs` and the defect heatmap both drop them), so this total always reconciles with what a caller can actually enumerate. DERIVED as the sum of `by_severity` rather than counted separately, so the total and the breakdown can never contradict each other." }, "by_severity": { "$ref": "#/components/schemas/OpenBugsBySeverity" }, "open_statuses": { "type": "array", "items": { "type": "string", "enum": ["open", "in_progress"] }, "description": "The exact `bugs.status` values counted above, published so the rule is machine-readable rather than inferred. This is the same list the Home stat card renders from and the same one the supporting partial index is built on, so the three cannot drift apart." } }, "required": ["open_count", "by_severity", "open_statuses"] }, "OpenBugsBySeverity": { "type": "object", "properties": { "P1": { "type": "integer", "description": "Critical." }, "P2": { "type": "integer", "description": "Major." }, "P3": { "type": "integer", "description": "Minor." }, "P4": { "type": "integer", "description": "Trivial." } }, "required": ["P1", "P2", "P3", "P4"], "description": "Open bugs per severity. The four keys are exhaustive — `bugs.severity` is CHECK-constrained to exactly P1..P4 — so these always sum to `open_count`." }, "ProjectCreateResponse": { "type": "object", "properties": { "project": { "$ref": "#/components/schemas/Project" } }, "required": ["project"] }, "Project": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string" }, "name": { "type": "string" }, "description": { "type": ["string", "null"] }, "workspace_id": { "type": "string", "format": "uuid" }, "created_at": { "type": "string", "format": "date-time" } }, "required": ["id", "slug", "name", "description", "workspace_id", "created_at"] }, "ProjectCreateBody": { "type": "object", "properties": { "name": { "type": "string", "description": "3–80 chars, at least one alphanumeric. Slug is auto-derived; names mapping to a reserved slug (api, new, settings, …) are rejected." }, "description": { "type": "string", "description": "Optional Markdown, max 5KB." } }, "required": ["name"] }, "RecentProjects": { "type": "object", "properties": { "projects": { "type": "array", "items": { "$ref": "#/components/schemas/RecentProject" }, "description": "Ordered by `last_activity_at` descending, name ascending as the tie-break. At most `limit` entries." } }, "required": ["projects"] }, "RecentProject": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string", "description": "URL slug, unique per workspace — the project route is `/projects/{slug}`." }, "name": { "type": "string" }, "module_count": { "type": "integer", "description": "Active (non-archived) modules in the project. An exact count, not a scan-derived floor." }, "atc_count": { "type": "integer", "description": "Active (non-archived) ATCs in the project. An exact count, not a scan-derived floor." }, "last_activity_at": { "type": "string", "format": "date-time", "description": "The newest of: an ATC written or revised, a module added, a run started/finished/aborted, and — as the floor — the project's own creation. Never null: a project that has never been touched still reports when it was created. Three things do NOT advance it: module renames and moves (`modules` carries no `updated_at`), marking a step inside an in-progress run (step marking does not write the `runs` row), and bug activity (deliberately excluded — that surface belongs to the Home open-bugs widget)." } }, "required": ["id", "slug", "name", "module_count", "atc_count", "last_activity_at"] }, "WorkspaceRestoreResponse": { "type": "object", "properties": { "workspaceId": { "type": "string", "format": "uuid" }, "workspaceName": { "type": "string" } }, "required": ["workspaceId", "workspaceName"] }, "WorkspaceCreateResponse": { "type": "object", "properties": { "workspace": { "$ref": "#/components/schemas/Workspace" } }, "required": ["workspace"] }, "Workspace": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "slug": { "type": "string" }, "name": { "type": "string" }, "owner_user_id": { "type": "string", "format": "uuid" }, "plan": { "type": "string", "enum": ["community", "cloud", "enterprise"] }, "created_at": { "type": "string", "format": "date-time" } }, "required": ["id", "slug", "name", "owner_user_id", "plan", "created_at"] }, "WorkspaceCreateBody": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 80, "example": "Acme QA" }, "slug": { "type": "string", "minLength": 3, "maxLength": 40, "description": "URL slug. Must match `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$`: lowercase letters and digits plus hyphens, 3 to 40 characters, and no leading or trailing hyphen. Uppercase, underscores, spaces and other characters are rejected with 422 `validation_failed`. A further 422 rejects 16 reserved slugs that would shadow app routes: `admin`, `api`, `app`, `auth`, `docs`, `invites`, `login`, `logout`, `onboarding`, `projects`, `public`, `qa`, `settings`, `static`, `workspaces`, `_next`. A slug already taken by another workspace returns 409 `conflict`.", "example": "acme-qa" } }, "required": ["name", "slug"] }, "WorkspaceListResponse": { "type": "object", "properties": { "workspaces": { "type": "array", "items": { "$ref": "#/components/schemas/WorkspaceWithRole" } } }, "required": ["workspaces"] }, "WorkspaceWithRole": { "allOf": [ { "$ref": "#/components/schemas/Workspace" }, { "type": "object", "properties": { "role": { "type": ["string", "null"], "enum": ["viewer", "member", "admin", "owner", null] } }, "required": ["role"] } ] }, "WorkspaceResponse": { "type": "object", "properties": { "workspace": { "$ref": "#/components/schemas/Workspace" } }, "required": ["workspace"] }, "WorkspacePatchBody": { "type": "object", "properties": { "name": { "type": "string", "minLength": 1, "maxLength": 80, "description": "New display name. The only mutable field today — slug rotation is post-MVP." } }, "description": "At least one field is required. An empty object `{}` parses cleanly against this schema but is rejected by the handler with 400 `bad_request` (\"Provide at least one field to update.\"), so the constraint is not visible in the schema itself." }, "WorkspaceDeleteResponse": { "type": "object", "properties": { "workspaceId": { "type": "string", "format": "uuid" }, "workspaceName": { "type": "string" }, "purgeDeadline": { "type": "string" }, "otherMemberCount": { "type": "integer" }, "wasActiveWorkspace": { "type": "boolean", "description": "Whether the deleted workspace was the caller's active one. `newActiveWorkspaceId`/`newActiveWorkspaceName` are only meaningful when this is true — they stay null when the deleted workspace was not active, which is not the same as \"no workspace left\" (AC-10)." }, "newActiveWorkspaceId": { "type": ["string", "null"], "format": "uuid" }, "newActiveWorkspaceName": { "type": ["string", "null"] } }, "required": [ "workspaceId", "workspaceName", "purgeDeadline", "otherMemberCount", "wasActiveWorkspace", "newActiveWorkspaceId", "newActiveWorkspaceName" ] } }, "parameters": {} }, "paths": { "/api/v1/user-stories/{id}/acceptance-criteria": { "post": { "tags": ["Acceptance Criteria"], "summary": "Add an acceptance criterion to a user story", "description": "Bearer `atc:write` (or cookie session). Member-only. Inserts at the given position (or the tail), atomically shifting active siblings down so positions stay gap-free.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcceptanceCriterionCreateBody" } } } }, "responses": { "201": { "description": "Criterion created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "acceptance_criterion": { "$ref": "#/components/schemas/AcceptanceCriterion" } }, "required": ["acceptance_criterion"] } } } }, "400": { "description": "Malformed id or body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "User story not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (title or detail size).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["Acceptance Criteria"], "summary": "List a story's active acceptance criteria", "description": "Bearer `atc:read` (or cookie session). Member-only. Returns active (non-archived) criteria in position order.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Criteria.", "content": { "application/json": { "schema": { "type": "object", "properties": { "acceptance_criteria": { "type": "array", "items": { "$ref": "#/components/schemas/AcceptanceCriterion" } } }, "required": ["acceptance_criteria"] } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/acceptance-criteria/{id}": { "get": { "tags": ["Acceptance Criteria"], "summary": "Read an acceptance criterion", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Criterion.", "content": { "application/json": { "schema": { "type": "object", "properties": { "acceptance_criterion": { "$ref": "#/components/schemas/AcceptanceCriterion" } }, "required": ["acceptance_criterion"] } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "patch": { "tags": ["Acceptance Criteria"], "summary": "Edit or reorder an acceptance criterion", "description": "Bearer `atc:write` (or cookie session). Member-only. Edits title/detail (direct update) and/or moves position (atomic, gap-free re-number).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AcceptanceCriterionUpdateBody" } } } }, "responses": { "200": { "description": "Updated.", "content": { "application/json": { "schema": { "type": "object", "properties": { "acceptance_criterion": { "$ref": "#/components/schemas/AcceptanceCriterion" } }, "required": ["acceptance_criterion"] } } } }, "400": { "description": "Malformed id or body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "delete": { "tags": ["Acceptance Criteria"], "summary": "Soft-delete (archive) an acceptance criterion", "description": "Bearer `atc:write` (or cookie session). Member-only. Archives the criterion, closes the position gap, and drops the parent story out of ready_to_test when it was the last active criterion (user_story_reverted).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Archived.", "content": { "application/json": { "schema": { "type": "object", "properties": { "acceptance_criterion": { "$ref": "#/components/schemas/AcceptanceCriterion" }, "user_story_reverted": { "type": "boolean" } }, "required": ["acceptance_criterion", "user_story_reverted"] } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Already archived.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/activity": { "get": { "tags": ["Activity"], "summary": "List the workspace activity feed, newest first", "description": "Bearer `atc:read` (or cookie session). The whole workspace shared feed, which is why it takes the read scope while a personal notification inbox does not (mirrors `GET /api/v1/tests/{id}/runs`). `bunkai_list_activity` is SECURITY INVOKER — it runs under the caller's own RLS, so a non-member workspace_id silently returns an empty page rather than leaking existence. Read-only, MVP-allowlisted event set only (8 of 12 write-site actions); no realtime, no defect activity. Pagination is KEYSET on `(created_at desc, id desc)`. An empty `items` is always a valid 200 — this endpoint never answers 404.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "workspace_id", "in": "query", "required": false, "schema": { "type": "string", "format": "uuid" }, "description": "Cookie sessions: optional, falls back to the active-workspace cookie. Bearer/PAT callers: REQUIRED — omitting it is a 422 (known gap, tracked as BK-182)." }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 30 }, "description": "Page size, 1..50 (default 30). Out of range is rejected (422); the RPC additionally clamps for direct callers." }, { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Opaque page token taken verbatim from the previous response's `next_cursor`. A malformed token returns 400 — it never silently falls back to the first page." } ], "responses": { "200": { "description": "One page of the activity feed (possibly empty).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivityPage" } } } }, "400": { "description": "An undecodable `cursor` (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — `workspace_id` missing for a Bearer/PAT caller, or `limit` outside 1..50 (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/admin/send-digest": { "get": { "tags": ["Admin"], "summary": "Send the daily unread-notifications email digest (cron entry point)", "description": "Internal — system/cron principal only (ADR-0017), not part of the public consumer surface. This is the verb Vercel Cron invokes on the 08:00 UTC schedule declared in `vercel.json`. Requires `Authorization: Bearer `, which Vercel attaches automatically when the project defines that variable.", "security": [ { "cronAuth": [] } ], "responses": { "200": { "description": "Digest run completed (per-recipient outcomes; a partial failure does not fail the whole request).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendDigestResponse" } } } }, "401": { "description": "Missing or invalid CRON_SECRET.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "Failed to load digest candidates.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "post": { "tags": ["Admin"], "summary": "Send the daily unread-notifications email digest (manual retry)", "description": "Internal — system/cron principal only (ADR-0017), not part of the public consumer surface. Same handler as the GET cron entry point, kept for manual same-day retries and for exercising the digest on staging, where no cron runs (Vercel registers cron definitions solely from the production deployment). Requires `Authorization: Bearer `.", "security": [ { "cronAuth": [] } ], "responses": { "200": { "description": "Digest run completed (per-recipient outcomes; a partial failure does not fail the whole request).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendDigestResponse" } } } }, "401": { "description": "Missing or invalid CRON_SECRET.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "Failed to load digest candidates.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/atcs": { "post": { "tags": ["ATCs"], "summary": "Create an ATC with steps and assertions", "description": "Bearer `atc:write` (or cookie session). Transactional create across atcs + steps + assertions + AC links. Validates that every acceptance criterion belongs to the user story and the module is the user story's module or a descendant in the same project. Emits an `atc.created` event.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AtcCreateBody" } } } }, "responses": { "201": { "description": "ATC created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "atc": { "$ref": "#/components/schemas/Atc" } }, "required": ["atc"] } } } }, "400": { "description": "Malformed body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "User story or module not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Slug collision (`slug_collision`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (`steps_position_invalid`, `ac_outside_user_story`, `module_outside_project_subtree`, title/limits, unrecognized `technique` / `priority` — `validation_failed` with `details.reason` `technique_invalid` / `priority_invalid`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/atcs/{id}/duplicate": { "post": { "tags": ["ATCs"], "summary": "Duplicate an ATC (deep-copy steps, assertions, AC bindings)", "description": "Bearer `atc:write` (or cookie session). Deep-copies the source ATC into a NEW ATC in the same project — every step and assertion (in order) plus the AC bindings — with a fresh slug, `version = 1`, and an independent set of child rows (editing the copy never changes the source). The title defaults to ` (copy)` unless the optional `new_title` field is supplied. The field is named `new_title`, NOT `title` — an unknown `title` key is stripped silently by the schema, so a request sending it succeeds and the copy quietly keeps the default `(copy)` name. Emits an `atc.created` event.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The source ATC to duplicate." } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AtcDuplicateBody" } } } }, "responses": { "201": { "description": "Duplicate created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "atc": { "$ref": "#/components/schemas/Atc" } }, "required": ["atc"] } } } }, "400": { "description": "Malformed id or body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Source ATC not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Slug collision (`slug_collision`). Retry.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (`title_too_long` when the computed `(copy)` title exceeds 200 chars, or title/limits).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/atcs/{id}": { "patch": { "tags": ["ATCs"], "summary": "Edit an ATC (full replace of steps and assertions)", "description": "Bearer `atc:write` (or cookie session). PUT-style full replace — omitted children are cleared, and so are omitted `technique` / `priority` (BK-399: an omitted key stores `null` = not specified, it does not preserve the previous value). An empty body is a 200 no-op (no version bump, no event). `user_story_id`, `module_id`, and `slug` are immutable. BK-21: edits propagate automatically to every Test that chains the ATC (Tests reference it by id, never copy its content), visible on the Test's next read. The response reports `affected_test_count` (DISTINCT chaining Tests — a Test that chains the ATC at multiple positions counts once; 0 for a no-op). Emits an `atc.updated` event carrying the in-transaction `affected_test_ids`.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "X-If-Match", "in": "header", "required": false, "schema": { "type": "string" }, "description": "Current version for optimistic locking. A mismatch returns 409. Uses a custom header instead of RFC 7232 `If-Match` because the Vercel edge intercepts `If-Match` and rewrites the response to 412 (BK-96); `If-Match` is still accepted as a fallback on non-Vercel deployments." } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AtcUpdateBody" } } } }, "responses": { "200": { "description": "ATC updated (or no-op for an empty body).", "content": { "application/json": { "schema": { "type": "object", "properties": { "atc": { "$ref": "#/components/schemas/Atc" }, "version": { "type": "integer", "description": "The ATC version after the edit (unchanged on a no-op)." }, "affected_test_count": { "type": "integer", "description": "DISTINCT Tests that chain this ATC and now reflect the edit. 0 for a no-op." } }, "required": ["atc", "version", "affected_test_count"] } } } }, "400": { "description": "Malformed id, body, or X-If-Match.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "ATC not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Version conflict (code `conflict`, `details.reason: version_conflict`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (`steps_position_invalid`, `ac_outside_user_story`, title/limits, unrecognized `technique` / `priority` — `validation_failed` with `details.reason` `technique_invalid` / `priority_invalid`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/atcs/{id}/usage": { "get": { "tags": ["ATCs"], "summary": "Report which Tests chain an ATC (\"used in N tests\")", "description": "Bearer `atc:read` (or cookie session). Returns the distinct Tests that chain this ATC — each with the positions the ATC occupies — plus a `count` of distinct Tests, ordered by Test title. Results are confined to the ATC's own workspace (Tests are workspace-scoped) and the caller must be an active member of it. A reachable ATC with no chaining Tests returns `count: 0` + an empty `used_in` (never 404). A nonexistent, archived, or cross-workspace ATC returns a uniform 404 (no existence leak).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The ATC to report usage for." } ], "responses": { "200": { "description": "Usage report (possibly empty).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AtcUsageReport" } } } }, "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "ATC not found (also returned for an ATC outside the caller's workspaces — no existence leak).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/atcs/search": { "get": { "tags": ["ATCs"], "summary": "Search ATCs by title and tags", "description": "Bearer `atc:read` (or cookie session). Project-scoped full-text search over ATC title + tags, ranked by relevance with a 7-day recency tie-break, optionally narrowed by a module subtree, layer, technique and/or priority (BK-399) — every narrow is ANDed with the others. `query` and `project_id` remain REQUIRED: this is a search endpoint, not a list endpoint, so a classification narrow alone is not a valid request. Results are restricted to the caller's active workspace memberships AND to the required `project_id`; a project outside those memberships returns no rows. Zero matches return an empty `items` array (never 404).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "Free-text query matched against ATC title + tags. Single token is prefix-aware (autocomplete); multiple words use AND semantics." }, "required": true, "description": "Free-text query matched against ATC title + tags. Single token is prefix-aware (autocomplete); multiple words use AND semantics.", "name": "query", "in": "query" }, { "schema": { "type": "string", "format": "uuid", "description": "Required. Scopes the search to a single project; a project outside the caller's active workspaces returns no rows." }, "required": true, "description": "Required. Scopes the search to a single project; a project outside the caller's active workspaces returns no rows.", "name": "project_id", "in": "query" }, { "schema": { "type": "string", "format": "uuid", "description": "Narrow results to this module and its descendant subtree." }, "required": false, "description": "Narrow results to this module and its descendant subtree.", "name": "module_id", "in": "query" }, { "schema": { "type": "string", "enum": ["UI", "API", "Unit"] }, "required": false, "name": "layer", "in": "query" }, { "schema": { "type": "string", "enum": [ "Equivalence Partitioning", "Boundary Value Analysis", "State Transition", "Decision Table", "Pairwise" ], "description": "BK-399 — narrow to one test-design technique. Exact display label, case-sensitive, not trimmed (URL-encode the spaces, e.g. `technique=Decision+Table`). Absent means no narrow; there is no sentinel for \"not specified\", so ATCs with a NULL technique are unreachable through this parameter." }, "required": false, "description": "BK-399 — narrow to one test-design technique. Exact display label, case-sensitive, not trimmed (URL-encode the spaces, e.g. `technique=Decision+Table`). Absent means no narrow; there is no sentinel for \"not specified\", so ATCs with a NULL technique are unreachable through this parameter.", "name": "technique", "in": "query" }, { "schema": { "type": "string", "enum": ["Critical", "High", "Medium", "Low"], "description": "BK-399 — narrow to one ATC priority (distinct from Bug severity). Same strictness and same absence semantics as `technique`." }, "required": false, "description": "BK-399 — narrow to one ATC priority (distinct from Bug severity). Same strictness and same absence semantics as `technique`.", "name": "priority", "in": "query" }, { "schema": { "type": "integer", "minimum": 1, "maximum": 50, "description": "1..50, default 20." }, "required": false, "description": "1..50, default 20.", "name": "limit", "in": "query" } ], "responses": { "200": { "description": "Ranked matches (possibly empty).", "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/AtcSearchResult" } } }, "required": ["items"] } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (empty/missing query, missing/invalid project_id, bad limit, bad layer, unrecognized technique / priority).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/auth/check-email": { "post": { "tags": ["Auth"], "summary": "Email-first routing probe", "description": "Returns whether an email is registered and whether it has been confirmed, so the login UI can route to the password, verify, or create step. Deliberately reveals existence (see ADR-0007 enumeration tradeoff); the real auth endpoints stay enumeration-safe.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CheckEmailBody" } } } }, "responses": { "200": { "description": "Email status.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CheckEmailResponse" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "429": { "description": "Rate limited.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/auth/confirm": { "post": { "tags": ["Auth"], "summary": "Verify email OTP → session + auto-minted PAT", "description": "Completes a verification-first sign-up by verifying the email OTP (a 6-to-8-digit numeric code). On success it establishes the Supabase session AND mints a fresh Bearer PAT in a single response — the same shape as POST /api/v1/auth/signin — so a new account can immediately authenticate subsequent requests.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConfirmBody" } } } }, "responses": { "200": { "description": "Verified + authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConfirmResponse" } } } }, "401": { "description": "Invalid or expired verification code.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "`pat_scopes` contained `workspace:admin`. The scope is schema-valid but rejected after parsing: headless auth has no `workspace_id` to bind an admin token to. Use `POST /api/v1/tokens` instead. See ADR-0005.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "429": { "description": "Rate limited.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/auth/magic-link": { "post": { "tags": ["Auth"], "summary": "Send a Supabase magic-link email", "description": "Server-side wrapper around `supabase.auth.signInWithOtp`. Sends a one-time link to the supplied email. Public — no auth required.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MagicLinkBody" } } } }, "responses": { "200": { "description": "Magic-link email enqueued.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MagicLinkResponse" } } } }, "422": { "description": "Request body failed validation.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "429": { "description": "Supabase OTP rate limit hit (free tier: 4/hour/email).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "502": { "description": "Upstream Supabase error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/auth/resend": { "post": { "tags": ["Auth"], "summary": "Resend the sign-up email verification code", "description": "Resends the 6-digit sign-up confirmation code for an account that is still pending verification. Takes only the email — no password — so it is safe to call from the verification screen without re-triggering account creation.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResendBody" } } } }, "responses": { "202": { "description": "Resend accepted; a new verification code has been emailed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResendResponse" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "429": { "description": "Rate limited.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/auth/signin": { "post": { "tags": ["Auth"], "summary": "Headless password sign-in + auto-minted PAT", "description": "Password sign-in for CLI / agent callers. Returns the Supabase session AND a freshly-minted Bearer PAT in a single response so the caller can immediately authenticate subsequent requests without a browser.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SigninBody" } } } }, "responses": { "200": { "description": "Authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SigninResponse" } } } }, "401": { "description": "Invalid credentials.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "`pat_scopes` contained `workspace:admin`. The scope is schema-valid but rejected after parsing: headless auth has no `workspace_id` to bind an admin token to. Use `POST /api/v1/tokens` instead. See ADR-0005.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/auth/signup": { "post": { "tags": ["Auth"], "summary": "Sign up + send 6-digit email verification", "description": "Registers an email + password account via the public sign-up path, which sends a 6-digit confirmation code by email. No session and no PAT are issued here — the account stays unconfirmed until the code is verified via POST /api/v1/auth/confirm.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignupBody" } } } }, "responses": { "202": { "description": "Sign-up accepted; verification code emailed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignupResponse" } } } }, "409": { "description": "Email already exists.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "429": { "description": "Rate limited.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/bugs": { "post": { "tags": ["Bugs"], "summary": "File a bug, linked to a failed run step or standalone", "description": "Bearer `atc:write` (or cookie session). Run-linked: body carries ONLY `run_step_id` — project/module/run/ATC context is derived server-side from the run (never client-supplied) and the target step must be `failed`. Standalone: body carries `project_id` + `module_id` directly. Either way `bunkai_create_bug` re-validates module ∈ project server-side. Always creates status `open`. Emits a `bug.filed` activity event.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "anyOf": [ { "$ref": "#/components/schemas/BugRunLinkedCreateBody" }, { "$ref": "#/components/schemas/BugStandaloneCreateBody" } ] } } } }, "responses": { "201": { "description": "Bug filed.", "content": { "application/json": { "schema": { "type": "object", "properties": { "bug": { "$ref": "#/components/schemas/BugDetail" } }, "required": ["bug"] } } } }, "400": { "description": "Malformed body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member with write access.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project, module, or run step not found (non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (title 5–200 chars, severity P1–P4, evidence links ≤10, module outside project, or the run-linked step is not `failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["Bugs"], "summary": "List and filter defects for a project, with aggregates", "description": "Bearer `atc:read` (or cookie session). Mirrors `GET /api/v1/activity` and `GET /api/v1/tests/{id}/runs`. `bunkai_list_bugs` is SECURITY INVOKER — it runs under the caller's own RLS, so a non-member `project_id` silently returns a 200 empty page rather than leaking existence. Module filter rolls up the full subtree (Decision 7); archived-module defects are hidden unconditionally (Decision 12); default sort is severity ascending then most-recent-first (Decision 5); pagination is the bugs-local 3-field keyset cursor (Decision 11).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "project_id", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The project to list defects for. A project the caller cannot see returns a 200 with an empty page (Decision 9) rather than a 403 or 404 — the same non-disclosure collapse GET /api/v1/activity uses for a foreign workspace_id." }, { "name": "module_id", "in": "query", "required": false, "schema": { "type": "string", "format": "uuid" }, "description": "Scope the list to this module and its full nested subtree (matched by `modules.path` prefix, depth up to 6 — Decision 7). A module outside `project_id` is rejected as `validation_failed` (`module_not_in_project`), but ONLY once `project_id` itself is confirmed visible (Decision 10)." }, { "name": "status", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Comma-separated list of statuses, OR-within-field (Decision 6), e.g. `open,in_progress`. Each value must be one of open | in_progress | resolved | closed — an unrecognized value (e.g. `in-progress` with a hyphen) is rejected as `validation_failed`." }, { "name": "severity", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Comma-separated list of severities, OR-within-field (Decision 6), e.g. `P1,P2`. Combined with `status`, filters are AND-across-fields. Each value must be one of P1 | P2 | P3 | P4." }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 30 }, "description": "Page size, 1..50 (default 30). Out of range is rejected (422); the RPC additionally clamps for direct callers." }, { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Opaque page token taken verbatim from the previous response's `next_cursor`. A malformed token returns 400 — it never silently falls back to the first page." } ], "responses": { "200": { "description": "One page of the filtered defect list, with aggregates over the full filtered set (possibly empty).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BugsListPage" } } } }, "400": { "description": "An undecodable `cursor` (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — missing/invalid `project_id`, an unrecognized `status`/`severity` value, `limit` outside 1..50, or `module_id` outside `project_id` (`module_not_in_project`, disclosed only once `project_id` is confirmed visible).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/bugs/{id}/assign": { "post": { "tags": ["Bugs"], "summary": "Assign, reassign, or unassign a bug", "description": "Bearer `atc:write` (or cookie session); member+ write access. `bunkai_assign_bug` (migration 0054) is a no-op (no new activity row) when the requested assignee is already the current one. Emits `bug.assigned` / `bug.reassigned` / `bug.unassigned` depending on the prior state.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BugAssignBody" } } } }, "responses": { "200": { "description": "Bug assignment updated. Returns the updated Bug.", "content": { "application/json": { "schema": { "type": "object", "properties": { "bug": { "$ref": "#/components/schemas/BugDetail" } }, "required": ["bug"] } } } }, "400": { "description": "Malformed id (not a UUID) or malformed JSON body (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope, or a workspace member without write access (`forbidden`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Bug not found — also returned for a caller who is not even a member of the bug's workspace (non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — the requested assignee is not an active member of this workspace (`assignee_not_workspace_member`), or is a view-only member (`assignee_view_only`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/bugs/{id}": { "get": { "tags": ["Bugs"], "summary": "Read a single defect's full record", "description": "Bearer `atc:read` (or cookie session). Any active workspace role, viewers included, may read (`bugs_select_workspace_member`, migration 0046). `bunkai_bug_json` is SECURITY INVOKER — it runs under the caller's own RLS, so a bug outside the caller's workspaces returns the same 404 as an unknown id (non-disclosing). A bug filed against a since-archived module still renders in full, with `module.archived_at` set — this read does NOT apply the archived-module exclusion the list endpoints use.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "The defect's full record.", "content": { "application/json": { "schema": { "type": "object", "properties": { "bug": { "$ref": "#/components/schemas/BugDetail" } }, "required": ["bug"] } } } }, "400": { "description": "Malformed id — not a UUID (`bad_request`). Computed from the string alone, no database access.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Bug not found — also returned for a well-formed id in a workspace the caller is not a member of (non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/bugs/{id}/status": { "post": { "tags": ["Bugs"], "summary": "Advance a bug's status one lifecycle stage at a time", "description": "Bearer `atc:write` (or cookie session); member+ write access. `bunkai_transition_bug_status` (migration 0054) enforces the lifecycle order open -> in_progress -> resolved -> closed: exactly one stage forward per call, never backward, never skipping a stage. Emits `bug.status_changed` with the previous status, new status, and current assignee.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BugStatusTransitionBody" } } } }, "responses": { "200": { "description": "Bug status updated. Returns the updated Bug.", "content": { "application/json": { "schema": { "type": "object", "properties": { "bug": { "$ref": "#/components/schemas/BugDetail" } }, "required": ["bug"] } } } }, "400": { "description": "Malformed id (not a UUID) or malformed JSON body (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope, or a workspace member without write access (`forbidden`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Bug not found — also returned for a caller who is not even a member of the bug's workspace (non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — the target status skips a lifecycle stage (`status_transition_skipped`), or moves backward / stays the same (`status_transition_backward`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/environments/{id}": { "patch": { "tags": ["Environments"], "summary": "Rename an environment", "description": "Member-only (role >= member). Same name rules as create (trim, 1–50 chars, case-insensitive uniqueness). The row id is unchanged, so runs that reference it keep referencing it. A collision returns 409; non-members return 403; a missing environment returns 404.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEnvironmentRenameBody" } } } }, "responses": { "200": { "description": "Environment renamed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEnvironmentRenameResponse" } } } }, "400": { "description": "Malformed environment id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Environment not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "An environment with this name already exists.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name empty or > 50 chars).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "delete": { "tags": ["Environments"], "summary": "Remove an environment", "description": "Member-only (role >= member). Hard-deletes an unused environment. BLOCKED with 409 when one or more runs reference the environment — the response message states how many. Non-members return 403; a missing environment returns 404.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Environment removed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEnvironmentDeleteResponse" } } } }, "400": { "description": "Malformed environment id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Environment not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The environment is in use by one or more runs and cannot be removed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/health": { "get": { "tags": ["Health"], "summary": "Liveness probe", "description": "Returns service identity, current environment, and a server timestamp. Always public.", "responses": { "200": { "description": "Service is up.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } }, "500": { "description": "Unhandled error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/imports": { "post": { "tags": ["Imports"], "summary": "Start an async Jira import", "description": "Bearer `atc:write` (or cookie session). Member-only. Enqueues a one-way Jira import for the project and processes it in the background; idempotent on re-run. At most one active import per project (409). Returns immediately with the job id.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ImportCreateBody" } } } }, "responses": { "202": { "description": "Import enqueued.", "content": { "application/json": { "schema": { "type": "object", "properties": { "import_job_id": { "type": "string", "format": "uuid" }, "status": { "type": "string" } }, "required": ["import_job_id", "status"] } } } }, "400": { "description": "Malformed body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "An import is already running for this project.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/imports/{id}": { "get": { "tags": ["Imports"], "summary": "Poll an import job", "description": "Bearer `atc:read` (or cookie session). Member-only. Returns the job status (queued | running | completed | failed) plus per-run counts and per-issue errors.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Job.", "content": { "application/json": { "schema": { "type": "object", "properties": { "import_job": { "$ref": "#/components/schemas/ImportJob" } }, "required": ["import_job"] } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/invites/accept": { "post": { "tags": ["Invites"], "summary": "Redeem a workspace invite token", "description": "Caller must be signed in. Caller email must match invite email. Adds membership in the invite's workspace + role; stamps acceptance on the invite.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InviteAcceptBody" } } } }, "responses": { "200": { "description": "Accepted.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InviteAcceptResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Invite email does not match caller.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Token invalid.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Invite already accepted / revoked / expired, or the caller already holds an equal-or-higher role (`already_member_equal_or_higher_role`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/me/active-workspace": { "post": { "tags": ["Identity"], "summary": "Set the active workspace for the current session", "description": "Validates membership, then sets the `bk_active_ws` httpOnly cookie. Supabase JWT is untouched.", "security": [ { "cookieAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActiveWorkspaceBody" } } } }, "responses": { "200": { "description": "Cookie set.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActiveWorkspaceResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Caller is not a member of the target workspace, or is a Personal Access Token — PATs have no switchable active workspace and must pass `workspace_id` explicitly per request (BK-316).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/me": { "get": { "tags": ["Identity"], "summary": "Introspect the authenticated principal", "description": "Returns the signed-in user, all workspaces they belong to, and the selected active workspace (driven by the bk_active_ws cookie).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "responses": { "200": { "description": "Identity snapshot.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MeResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/milestones": { "get": { "tags": ["Milestones"], "summary": "List a project's milestones", "description": "Lists the project's milestones ordered by target date (ascending), id ascending as the tie-break. Visible to any workspace member; a non-member receives an empty list.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Milestones listed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MilestoneListResponse" } } } }, "400": { "description": "Malformed project id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "post": { "tags": ["Milestones"], "summary": "Create a milestone in a project", "description": "Member-only (role >= member). Normalizes the name (collapses internal whitespace, then trims) and enforces 1–100 chars; description is capped at 500 chars. The target date must be today or later and within 5 years of the write date — both server UTC. The name is unique per project (case-insensitive, whitespace-normalized); a duplicate returns 409. Non-members return 403.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MilestoneCreateBody" } } } }, "responses": { "201": { "description": "Milestone created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MilestoneCreateResponse" } } } }, "400": { "description": "Malformed project id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A milestone with this name already exists in the project.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name/description length, or target date out of bounds).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/milestones/{id}": { "patch": { "tags": ["Milestones"], "summary": "Edit a milestone", "description": "Member-only (role >= member). Same normalize/length rules as create. The target-date bounds (today-or-later, within 5 years) are enforced ONLY when the submitted date differs from the milestone's current stored value — an unchanged past-dated milestone stays editable (e.g. a description-only edit). Non-members receive a non-disclosing 404; a member with only the viewer role receives 403.\n\nThis is a FULL REPLACE, not a partial patch: `description` defaults to empty when omitted, so a body carrying only `name` and `target_date` silently CLEARS an existing description. Send all three fields on every edit.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MilestoneUpdateBody" } } } }, "responses": { "200": { "description": "Milestone updated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MilestoneUpdateResponse" } } } }, "400": { "description": "Malformed milestone id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Milestone not found (or not visible to the caller).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A milestone with this name already exists in the project.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name/description length, or target date out of bounds when changed).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/modules/{id}": { "patch": { "tags": ["Modules"], "summary": "Rename, edit, or move a module", "description": "Member-only (role >= member). Renaming rebuilds the module path and every descendant path; `parent_module_id` moves the module (and its subtree) to a new parent or the project root. `parent_module_id` is mutually exclusive with `name`/`description` — a combined update+move request returns 422 (`combined_update_and_move`); send them as separate PATCH calls. A sibling/destination slug collision returns 409; a cycle, depth-overflow, or invalid target returns 422. An archived module reads as 404. Viewers/non-members return 403.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ModuleUpdateBody" } } } }, "responses": { "200": { "description": "Module updated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ModuleUpdateResponse" } } } }, "400": { "description": "Malformed module id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Module not found or archived.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The new name or move destination collides with a sibling module.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name, description, combined update+move, or move: cycle / depth / invalid parent).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "delete": { "tags": ["Modules"], "summary": "Soft-delete (archive) a module and its subtree", "description": "Member-only (role >= member). Archives the module, its descendant modules, and the linked user stories, acceptance criteria, and ATCs in one transaction. Returns per-table counts. Archiving an already-archived module returns 409; viewers/non-members return 403.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Module and its subtree archived.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ModuleArchiveResponse" } } } }, "400": { "description": "Malformed module id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Module not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Module is already archived.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/modules/{id}/user-stories": { "post": { "tags": ["User Stories"], "summary": "Create a user story under a module", "description": "Bearer `atc:write` (or cookie session). Member-only. Anchors the story to the module, validates the title (3–200) and optional Jira key, sanitizes the Markdown description. Duplicate Jira key in the project returns 409.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserStoryCreateBody" } } } }, "responses": { "201": { "description": "Story created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "user_story": { "$ref": "#/components/schemas/UserStory" } }, "required": ["user_story"] } } } }, "400": { "description": "Malformed id or body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Module not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Jira key already linked in this project.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (title, Jira key, or description size).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["User Stories"], "summary": "List a module's active user stories", "description": "Bearer `atc:read` (or cookie session). Member-only. Returns active (non-archived) stories, newest first.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Stories.", "content": { "application/json": { "schema": { "type": "object", "properties": { "user_stories": { "type": "array", "items": { "$ref": "#/components/schemas/UserStory" } } }, "required": ["user_stories"] } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/notification-preferences": { "get": { "tags": ["Notifications"], "summary": "List my notification preferences", "description": "Returns the caller's own notification preferences grid (BK-213): 4 editable cells (`run_lifecycle` / `bug_lifecycle` x `in_app` / `email`, `enabled: true` when never touched) plus 2 structurally-locked `mentions` cells (`locked: true`, always `enabled: false`, immutable until Team Chat ships). Personal and GLOBAL — no workspace scoping.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "responses": { "200": { "description": "The caller's preferences grid.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationPreferencesListResponse" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "patch": { "tags": ["Notifications"], "summary": "Update one notification preference cell", "description": "Instant-save toggle for one (event_type, channel) cell — last-write-wins, no lock (QA Refinement Decision 2). `mentions` is deliberately excluded from `event_type` — a request naming it fails validation (`validation_failed`, 422), on top of migration 0062's own DB-level lock.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationPreferencePatchBody" } } } }, "responses": { "200": { "description": "The cell is now persisted with this value.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationPreferencePatchResponse" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Invalid body — including an `event_type` of `mentions` (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/notifications/{id}/read": { "post": { "tags": ["Notifications"], "summary": "Mark one notification read", "description": "Marks one of the caller's own notifications read. Plain RLS-scoped update, no RPC — `notifications_update_recipient_member` (migration 0053_notifications.sql) is the entire authorization surface, so a foreign id or another recipient's row is indistinguishable from a nonexistent one (404, non-disclosing). Idempotent: marking an already-read notification succeeds again rather than erroring.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "The notification is now marked read.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarkNotificationReadResponse" } } } }, "400": { "description": "The id is not a UUID (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Notification not found, not the caller's own, or the caller lost workspace membership (`not_found`, non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/atcs/export": { "get": { "tags": ["ATCs"], "summary": "Export a Project's whole ATC library as a CSV file", "description": "Bearer `atc:read` (or cookie session). Columns, fixed order: ATC ID, Slug, Title, Module, Layer, Tags, Status. Multiple Tags for one ATC join into a single cell with `; ` (semicolon-space). Any cell containing a comma, a double quote, or a line break is RFC4180-quoted, with embedded double quotes doubled. A cell whose content starts with `=`, `+`, `-`, `@`, a tab, or a CR is prefixed with a literal `'` before that escaping, to neutralize spreadsheet formula injection (OWASP guidance). The body is prefixed with a UTF-8 BOM so non-ASCII Title/Tag content renders correctly in Windows Excel. A Project with zero ATCs returns a header-only CSV (200, never an error). No row cap: every non-archived ATC is included regardless of library size.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The Project whose ATC library to export." } ], "responses": { "200": { "description": "The Project's ATC library as CSV, `Content-Disposition: attachment`.", "content": { "text/csv": { "schema": { "type": "string" } } } }, "400": { "description": "Malformed Project id (not a UUID) (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found (also returned for a Project outside the caller's workspaces — no existence leak).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/bugs/heatmap": { "get": { "tags": ["Bugs"], "summary": "Compute a per-module defect heatmap (count + week-over-week trend) for a chosen window", "description": "Bearer `atc:read` (or cookie session). Mirrors GET /api/v1/projects/{id}/metrics/recovery-cycles. One SECURITY DEFINER RPC (`bunkai_report_project_defect_heatmap`) resolves the Project's workspace and re-checks ACTIVE membership in-band; any role reads, viewers included. No pagination or filters: the whole-project rollup is small and bounded. Each module's defect_count rolls up its full descendant subtree (path-prefix match); archived modules are excluded from the heatmap by default but a filed defect against a since-archived descendant still counts toward an active ancestor. A Project with zero bugs returns every active module at Clean/0 (never a 404) — a 404 means the Project itself is missing, foreign, or unreadable.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The Project whose defect heatmap to read." }, { "name": "window", "in": "query", "required": false, "schema": { "type": "string", "enum": ["7d", "30d", "90d"], "default": "30d" }, "description": "The rollup window for defect_count. Defaults to 30d. An unsupported value is a 400 bad_request (not the repo's usual validation_failed/422 — AC-11's explicit wording for this error)." } ], "responses": { "200": { "description": "The defect heatmap report.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DefectHeatmapReport" } } } }, "400": { "description": "Malformed Project id (not a UUID), or an unsupported window value (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found (also returned for a Project outside the caller's workspaces — no existence leak).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/bugs": { "get": { "tags": ["Bugs"], "summary": "List a project's bugs, newest first", "description": "Bearer `atc:read` (or cookie session). Bare, unfiltered list (no query params yet — BK-41/BK-42 extend this route additively). Visible to any active workspace member (viewers included). Non-disclosure: a missing, non-visible, or foreign-workspace Project all return the same 404.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Bugs listed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BugListResponse" } } } }, "400": { "description": "Malformed project id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found (non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/coverage": { "get": { "tags": ["Coverage"], "summary": "Surface a Project's untested acceptance criteria and modules, with a never-run indicator", "description": "Bearer `atc:read` (or cookie session). Mirrors `GET /api/v1/projects/{id}/runs/report`. One SECURITY DEFINER RPC (`bunkai_report_project_coverage`) resolves the Project's workspace and re-checks ACTIVE membership in-band; any role reads, viewers included (PO decision Q5 — this is not a privileged QA-only screen). No pagination or query parameters: the whole-project rollup is small and bounded, and the UI filters the returned payload client-side. A Project with zero acceptance criteria returns zeroed `kpis` and empty arrays (never a 404) — a 404 means the Project itself is missing, foreign, or unreadable.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The Project whose coverage rollup to read." } ], "responses": { "200": { "description": "The Project's coverage rollup.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CoveragePayload" } } } }, "400": { "description": "Malformed Project id (not a UUID) (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found (also returned for a Project outside the caller's workspaces — no existence leak).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/environments": { "get": { "tags": ["Environments"], "summary": "List a project's environments", "description": "Lists the project's environments ordered by name (ascending). Visible to any workspace member; a non-member receives an empty list.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Environments listed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEnvironmentListResponse" } } } }, "400": { "description": "Malformed project id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "post": { "tags": ["Environments"], "summary": "Add an environment to a project", "description": "Member-only (role >= member). Trims the name and enforces 1–50 chars. The name is unique per project (case-insensitive); a duplicate returns 409. Non-members return 403.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEnvironmentCreateBody" } } } }, "responses": { "201": { "description": "Environment created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectEnvironmentCreateResponse" } } } }, "400": { "description": "Malformed project id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "An environment with this name already exists.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name empty or > 50 chars).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/metrics/recovery-cycles": { "get": { "tags": ["Metrics"], "summary": "Compute per-user-story recovery-cycle time (first failing run -> first all-passing run)", "description": "Bearer `atc:read` (or cookie session). Mirrors `GET /api/v1/projects/{id}/coverage`. One SECURITY DEFINER RPC (`bunkai_report_project_recovery_cycles`) resolves the Project's workspace and re-checks ACTIVE membership in-band; any role reads, viewers included. No pagination or query parameters: the whole-project rollup is small and bounded. Computed entirely from Run history (`runs`/`run_atcs`) — no Bugs-domain read (Decision 1). A Project with zero Runs returns an empty `items` with `median_recovery_seconds: null` and zeroed counts (never a 404) — a 404 means the Project itself is missing, foreign, or unreadable.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The Project whose recovery-cycle report to read." } ], "responses": { "200": { "description": "The recovery-cycle report.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCycleReport" } } } }, "400": { "description": "Malformed Project id (not a UUID) (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found (also returned for a Project outside the caller's workspaces — no existence leak).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/modules": { "post": { "tags": ["Modules"], "summary": "Create a module in a project", "description": "Member-only (role >= member). Auto-derives a per-parent-unique path segment from the name. Modules nest up to 6 levels; depth >= 5 returns a soft warning, depth > 6 is rejected. Duplicate sibling path returns 409; non-members return 403.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ModuleCreateBody" } } } }, "responses": { "201": { "description": "Module created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ModuleCreateResponse" } } } }, "400": { "description": "Malformed project id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A module with this name already exists under the same parent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name, description, depth, or parent).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/runs/report": { "get": { "tags": ["Runs"], "summary": "Filter a Project's Runs by date range, module, status and executor, with matching pass/fail totals", "description": "Bearer `atc:read` (or cookie session). The PAT scope catalog has no run-read scope, so run reporting reuses the read scope rather than minting a fifth one (BK-499, superseding this story Key Contract Decision that deferred the question). One SECURITY DEFINER RPC (`bunkai_report_project_runs`) resolves the Project's workspace and re-checks ACTIVE membership in-band; any role reads, viewers included. Every filter is AND-composed and every filter composes with pagination server-side. Unlike `GET /api/v1/tests/{id}/runs`, rows are NOT restricted to terminal statuses — a currently-`running` Run is a legitimate row — and `totals` is recomputed from the SAME filtered set as the rows (not all-time), so the numbers change as filters narrow (Business Rule #3 / Technical Decision D2). Pagination is KEYSET on `(started_at desc, id desc)`, reusing the same opaque cursor contract as the run-history endpoint. A Project with no matching Runs returns an empty `items` with zeroed `totals` (never a 404) — a 404 means the Project itself is missing, foreign, or unreadable.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The Project whose Run report to read." }, { "name": "date_from", "in": "query", "required": false, "schema": { "type": "string", "format": "date" }, "description": "Inclusive lower bound on started_at, matched as a UTC calendar day (YYYY-MM-DD). No projects.timezone column exists to interpret against (Technical Decision D3)." }, { "name": "date_to", "in": "query", "required": false, "schema": { "type": "string", "format": "date" }, "description": "Inclusive upper bound on started_at, matched as a UTC calendar day (YYYY-MM-DD). Rejected (422) when earlier than date_from." }, { "name": "module_id", "in": "query", "required": false, "schema": { "type": "string", "format": "uuid" }, "description": "Narrow the report to Runs whose snapshotted module matches this Project module." }, { "name": "status", "in": "query", "required": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["passed", "failed", "aborted"] } }, "style": "form", "explode": true, "description": "Narrow to one or more statuses, repeatable (?status=passed&status=failed). `running` is NOT an accepted filter value (422) — an in-progress Run can still appear as a row, it just is not a selectable filter target (Technical Decision D4)." }, { "name": "executor", "in": "query", "required": false, "schema": { "type": "array", "items": { "type": "string", "enum": ["human", "agent", "ci"] } }, "style": "form", "explode": true, "description": "Narrow to one or more executor modes, repeatable (?executor=human&executor=agent)." }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 50 }, "description": "Page size, 1..50 (default 50). Out of range is rejected (422); the RPC additionally clamps for direct callers." }, { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Opaque page token taken verbatim from the previous response's `next_cursor`. The token is base64url and therefore URL-safe as issued — it needs no escaping and must not be re-encoded. A malformed token returns 400 — it never silently falls back to the first page." } ], "responses": { "200": { "description": "One page of the Run report (possibly empty).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunReportPage" } } } }, "400": { "description": "Malformed Project id (not a UUID) or an undecodable `cursor` (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found (also returned for a Project outside the caller's workspaces — no existence leak).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — `date_to` before `date_from`, a `status`/`executor` value outside its enum (incl. `status=running`), or `limit` outside 1..50 (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/test-plans": { "get": { "tags": ["Test Plans"], "summary": "List a project's test plans", "description": "Lists the project's test plans newest first (created_at descending, id descending as the tie-break). Visible to any workspace member including viewers; a non-member receives an empty list.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Test plans listed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanListResponse" } } } }, "400": { "description": "Malformed project id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "post": { "tags": ["Test Plans"], "summary": "Create a test plan in a project", "description": "Member-only (role >= member), re-checked live server-side on every call — a viewer calling this endpoint directly receives 403 regardless of what the client believes its role to be. Normalizes the name (collapses internal whitespace, then trims) and enforces 1–100 chars; description is capped at 500 chars and goal at 100. The name is unique per project (case-insensitive, whitespace-normalized) via a database unique index, so two concurrent creates of the same name resolve to exactly one success and one 409. The new plan is always created with status \"open\" and no member tests.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanCreateBody" } } } }, "responses": { "201": { "description": "Test plan created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanCreateResponse" } } } }, "400": { "description": "Malformed project id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope, or the caller is a viewer / not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Project not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A test plan with this name already exists in the project.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name, description or goal length).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/projects/{id}/traceability": { "get": { "tags": ["Traceability"], "summary": "Render a User Story's full acceptance-criteria-to-defect evidence chain in one read", "description": "Bearer `atc:read` (or cookie session). Mirrors `GET /api/v1/projects/{id}/coverage`. One SECURITY DEFINER RPC (`bunkai_report_story_traceability`) resolves the User Story's Project via its Module (never the nullable `user_stories.project_id`) and re-checks ACTIVE membership in-band; any role reads, viewers included. No pagination: one story's own chain is small and bounded, and round trips never scale with AC/ATC/Test/Run counts. Archived acceptance criteria and ATCs (including under an archived ancestor Module) are excluded from the chain; an archived STORY itself still renders in full.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The Project the User Story belongs to. This is a consistency assertion on the URL itself, never the scope parameter: it is checked against the Story's real Project (resolved via module_id, under the caller's own RLS) and a mismatched pair is rejected — see the 404 response below and route.ts." }, { "name": "story", "in": "query", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The User Story whose evidence chain to read." } ], "responses": { "200": { "description": "The User Story's evidence chain.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StoryTraceabilityPayload" } } } }, "400": { "description": "Malformed Project id or missing/malformed `story` query parameter (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "User Story not found. Also returned, byte-identical, for a Story outside the caller's workspaces, or for a Story that does not belong to the `{id}` Project asserted in the URL — no existence leak, never a 403.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1": { "get": { "tags": ["Health"], "summary": "API version discovery", "description": "Discovery endpoint that points clients (CLI, agents, browsers) at the OpenAPI spec and the Scalar docs UI. Public.", "responses": { "200": { "description": "API banner.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiVersionResponse" } } } }, "500": { "description": "Unhandled error.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/runs": { "post": { "tags": ["Runs"], "summary": "Start a manual Run of a Test in a chosen environment", "description": "Bearer `run:execute` (or cookie session). Transactional create via one SECURITY DEFINER RPC: snapshots the Test's ATC chain into run_atcs/run_steps (each `pending`), validates the environment belongs to the Test's Project and that the Test has ≥1 executable step, and enforces a 24h same-token idempotency window. Cookie sessions run as `human`; PAT callers may declare `agent` / `ci`. Returns 201 on create, 200 when the 24h same-token window returned an existing Run.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "Idempotency-Key", "in": "header", "required": true, "schema": { "type": "string", "pattern": "^[\\w-]{8,128}$" }, "description": "Required. 8–128 chars, [a-zA-Z0-9_-]. The HTTP request-replay guard: a replay with the same key and payload returns the stored response; the same key with a different payload returns 409 `conflict`. Distinct from the domain `start_token` (the per-Test 24h window). Window: 24h." } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunCreateBody" } } } }, "responses": { "200": { "description": "Replay: the same (test_id, start_token) within 24h returned the existing Run.", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "$ref": "#/components/schemas/Run" } }, "required": ["run"] } } } }, "201": { "description": "Run started.", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "$ref": "#/components/schemas/Run" } }, "required": ["run"] } } } }, "400": { "description": "Malformed body or missing/invalid Idempotency-Key (`bad_request`, `idempotency_key_required`, `idempotency_key_invalid`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing run:execute scope or not a workspace member with write access (non-disclosing — also returned for a Test the caller cannot see).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Idempotency-Key reused with a different payload, or a request with the same key is still in flight (`conflict`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (`validation_failed` for body shape, `no_executable_steps` when the Test has no executable steps, `environment_invalid` when the environment is not configured for the Test's Project).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/runs/{id}/abort": { "post": { "tags": ["Runs"], "summary": "Abort an in-progress Run with a reason", "description": "Bearer `run:execute` (or cookie session); member+ write access. Transactional abort via one SECURITY DEFINER RPC: closes the Run as `aborted` (sets `finished_at`, records the reason, bumps `version`), marks every not-yet-executed step `skipped`, and preserves already-recorded step results. Only a `running` Run can be aborted — an already-closed Run returns 409. The reason is trimmed then required to be 3..500 characters. Abort is first-wins: a concurrent double-submit serializes on a row lock and the loser gets 409.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunAbortBody" } } } }, "responses": { "200": { "description": "Run aborted. Returns the updated Run with `status: aborted` and `abort_reason`.", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "$ref": "#/components/schemas/Run" } }, "required": ["run"] } } } }, "400": { "description": "Malformed id (not a UUID) or malformed JSON body (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing run:execute scope or not a workspace member with write access (non-disclosing — also returned for a Run the caller cannot see).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The run is already closed and cannot be aborted (`conflict`, reason `run_not_abortable`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — the reason is shorter than 3 or longer than 500 characters (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/runs/{id}/finish": { "post": { "tags": ["Runs"], "summary": "Finish an in-progress Run with a final verdict", "description": "Bearer `run:execute` (or cookie session); member+ write access. Transactional finish via one SECURITY DEFINER RPC: closes the Run with the chosen final verdict `passed` or `failed` (sets `finished_at`, bumps `version`), marks every not-yet-executed step `skipped`, and preserves already-recorded step results. Only a `running` Run can be finished — an already-closed Run (passed/failed/aborted) returns 409. A human, an AI Test Agent, and a CI pipeline are handled identically. Finish is first-wins: a concurrent double-submit (or a race with abort) serializes on a row lock and the loser gets 409.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunFinishBody" } } } }, "responses": { "200": { "description": "Run finished. Returns the updated Run with `status` set to the final verdict (`passed`/`failed`) and `finished_at` stamped.", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "$ref": "#/components/schemas/Run" } }, "required": ["run"] } } } }, "400": { "description": "Malformed id (not a UUID) or malformed JSON body (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing run:execute scope or not a workspace member with write access (non-disclosing — also returned for a Run the caller cannot see).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The run is already closed and cannot be finished (`conflict`, reason `run_not_finishable`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — no final verdict of passed or failed was supplied (`validation_failed`, reason `finish_verdict_invalid`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/runs/{id}": { "get": { "tags": ["Runs"], "summary": "Read a Run with its snapshot chain fully expanded", "description": "Bearer `atc:read` (or cookie session). Viewer role suffices; no write scope required. Returns the Run header plus the ordered run_atcs, each with its ordered run_steps — the immutable snapshot taken at start. Non-disclosing: missing, not-visible, and foreign-workspace Runs all return an identical 404 — never 403, never an existence echo.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "The expanded Run.", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "$ref": "#/components/schemas/Run" } }, "required": ["run"] } } } }, "400": { "description": "Malformed id (not a UUID).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Run not found (missing, not visible, or foreign workspace — non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/runs/{id}/steps/{stepId}/mark": { "post": { "tags": ["Runs"], "summary": "Mark a run step passed, failed, or blocked", "description": "Bearer `run:execute` (or cookie session); member+ write access. Transactional mark via one SECURITY DEFINER RPC: UPDATEs the step in place (no history table — the most recent result stands, so re-marking a step is always last-write-wins) and recomputes the parent ATC verdict from its full current sibling step set (fail overrides everything, else blocked overrides passed, else passed; the verdict stays \"pending\" while ANY sibling step is still pending). Only a `running` Run accepts new step results — an already-closed Run (passed/failed/aborted) returns 409. Returns the composed Run, so the caller reads the updated step and the recomputed ATC verdict off the same response abort/finish already use.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "stepId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunStepMarkBody" } } } }, "responses": { "200": { "description": "Step marked. Returns the updated Run with the step's new status/note/evidence_url/executed_at and the recomputed parent ATC verdict.", "content": { "application/json": { "schema": { "type": "object", "properties": { "run": { "$ref": "#/components/schemas/Run" } }, "required": ["run"] } } } }, "400": { "description": "Malformed ids (not UUIDs) or malformed JSON body (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing run:execute scope or not a workspace member with write access (non-disclosing — also returned for a Run/step the caller cannot see).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Run step not found — missing, or belonging to a different run than {id} (non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The run is already closed and cannot accept new step results (`conflict`, reason `run_step_marking_closed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — status is not one of passed, failed, or blocked, note/evidence_url exceed 2000 characters, or evidence_url is not an http:// or https:// URL (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/search": { "get": { "tags": ["Search"], "summary": "Cross-entity workspace search", "description": "Bearer `atc:read` (or cookie session). Searches ATCs, Tests, Projects, Modules, Bugs and Runs inside ONE workspace and returns up to 5 matches per entity type, each with a ready-to-navigate `href`.\n\nThere is deliberately no workspace path segment and no workspace query parameter — the caller is never the authority on scope. A cookie session is scoped to its active workspace (the `bk_active_ws` cookie, falling back to the caller's oldest workspace); a Bearer PAT is scoped to the workspace it was issued against.\n\nScope failures never disclose existence: an unknown, foreign or inaccessible workspace and a query with no matches all return the same `200 { \"data\": [], \"truncated\": false }`. This route answers no 403 or 404 for scope reasons.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "schema": { "type": "string", "minLength": 2, "description": "Required. Free-text query, trimmed server-side; it must be at least 2 characters AFTER trimming, so `\" a \"` fails with 422. The client is expected never to send a shorter query in the first place — this is a defensive backstop." }, "required": true, "description": "Required. Free-text query, trimmed server-side; it must be at least 2 characters AFTER trimming, so `\" a \"` fails with 422. The client is expected never to send a shorter query in the first place — this is a defensive backstop.", "name": "q", "in": "query" }, { "schema": { "type": "integer", "minimum": 1, "maximum": 20, "description": "1..20, default 20. A per-request ceiling only: the underlying RPC independently caps each entity group at 5 rows, so raising `limit` never returns more than 5 of any one type." }, "required": false, "description": "1..20, default 20. A per-request ceiling only: the underlying RPC independently caps each entity group at 5 rows, so raising `limit` never returns more than 5 of any one type.", "name": "limit", "in": "query" } ], "responses": { "200": { "description": "Matches, grouped-capped and possibly empty.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchPageResponse" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "The Bearer PAT does not carry the `atc:read` scope (`forbidden`). Enforced by the gateway before the handler runs; cookie sessions hold every capability and never hit this.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Any of: `q` missing or shorter than 2 characters after trimming; `limit` not an integer in 1..20 (`validation_failed`). ALSO returned — unlike every other workspace-scoped route, which answers 403 — when a Bearer PAT is not bound to any workspace, or when a cookie session has no resolvable active workspace.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/test-plans/{id}": { "patch": { "tags": ["Test Plans"], "summary": "Edit a test plan", "description": "Member-only (role >= member), re-checked live server-side on every call. Same normalize/length rules as create, applied unconditionally to every field — renaming into an existing name in the same project returns 409 under the identical case-insensitive, whitespace-normalized rule that governs create; renaming a plan to the name it already holds is not a conflict. Editing is NOT restricted to the plan's creator: any member of the workspace may edit any plan in it. Non-members receive a non-disclosing 404; a member with only the viewer role receives 403. A plan that is no longer open cannot be edited.\n\nThis is a FULL REPLACE, not a partial patch: `description` and `goal` default to empty when omitted, so a body carrying only `name` clears both. Send all three fields.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanUpdateBody" } } } }, "responses": { "200": { "description": "Test plan updated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanUpdateResponse" } } } }, "400": { "description": "Malformed test plan id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope, or the caller is a viewer.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test plan not found (or not visible to the caller).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A test plan with this name already exists in the project, or the plan is no longer open.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (name, description or goal length).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/test-plans/{id}/tests/{testId}": { "delete": { "tags": ["Test Plans"], "summary": "Remove a test from a test plan", "description": "Member-only (role >= member), re-checked live server-side, and only while the plan is Open. Removing a membership never deletes or alters the Test itself, and never affects the Test's membership in any other plan.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "testId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Test removed from the plan.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanRemoveTestResponse" } } } }, "400": { "description": "Malformed test plan id or test id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope, or the caller is a viewer.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test plan not found, or this test is not a member of it.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The plan is closed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/test-plans/{id}/tests": { "get": { "tags": ["Test Plans"], "summary": "List a test plan's member tests", "description": "Visible to any workspace member of the plan, viewers included — seeing membership is role-agnostic (only add/remove is gated). Membership is a reference: the same Test may appear in several plans, and removing it here never alters the Test.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Member tests listed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanMemberTestListResponse" } } } }, "400": { "description": "Malformed test plan id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test plan not found (or not visible to the caller).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "post": { "tags": ["Test Plans"], "summary": "Add tests to a test plan", "description": "Member-only (role >= member), re-checked live server-side, and only while the plan is Open. Every submitted Test id must belong to the plan's own project (derived from its chained ATCs); a mismatch, a nonexistent id, or a foreign-workspace id are all rejected uniformly (422 test_outside_plan_project), with no id disclosed back. A Test already in the plan rejects the WHOLE request (409 conflict) rather than partially applying. Requires an `Idempotency-Key` header; a rapid double-submit of the same selection is deduplicated by the header AND independently by the database unique constraint.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanAddTestsBody" } } } }, "responses": { "201": { "description": "Tests added.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestPlanAddTestsResponse" } } } }, "400": { "description": "Malformed test plan id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope, or the caller is a viewer.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test plan not found (or not visible to the caller).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A submitted test is already in the plan, or the plan is closed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Empty test_ids, or a submitted test does not belong to the plan's project.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tests/{id}/reorder": { "patch": { "tags": ["Tests"], "summary": "Reorder the ATC chain inside a Test", "description": "Bearer `atc:write` (or cookie session). Rearranges the Test's ATC chain, preserving the exact step set (no add/remove). Body is the complete new order of `step_ids`. One SECURITY DEFINER RPC enforces the write gate, set equality, optimistic lock, no-op detection, and the atomic position rewrite. Submitting the current order is a 200 no-op — no version bump, no `updated_at` change, no event. A real reorder emits a single `test.reordered` activity-log event with `old_chain` / `new_chain` (atc_id arrays).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "X-If-Match", "in": "header", "required": false, "schema": { "type": "string" }, "description": "Current Test version for optimistic locking (lenient — absent skips the guard). A mismatch returns 409 with `details.current_version` and `details.current_chain`. Uses a custom header instead of RFC 7232 `If-Match` because the Vercel edge rewrites `If-Match` to 412 (BK-96); `If-Match` is accepted as a fallback off-Vercel." } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestReorderBody" } } } }, "responses": { "200": { "description": "Reordered (or a no-op when the submitted order is unchanged). Returns the expanded Test.", "content": { "application/json": { "schema": { "type": "object", "properties": { "test": {} } } } } }, "400": { "description": "Malformed id, body, or X-If-Match (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a workspace member with write access (`forbidden`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test not found / not visible — non-disclosing (`not_found`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Version conflict (`conflict`, `details.reason: version_conflict`, with `current_version` + `current_chain`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Empty chain or duplicate step ids (`chain_invalid`), or the submitted set is not the Test's steps (`chain_mismatch`, with `details.missing` / `details.extra`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tests/{id}": { "get": { "tags": ["Tests"], "summary": "Read a Test with its chain of ATCs fully expanded", "description": "Bearer `atc:read` (or cookie session). Viewer role suffices; no write scope required. Returns the Test header plus the ordered chain of ATCs, each expanded inline with its ordered steps and assertions, in one round trip. Live content (references, not snapshots). Non-disclosing: missing, not-visible, and foreign-workspace Tests all return an identical 404 — never 403, never an existence echo.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "expand", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Accepted for forward-compatibility; the response is always fully expanded in the MVP." } ], "responses": { "200": { "description": "The expanded Test.", "content": { "application/json": { "schema": { "type": "object", "properties": { "test": { "$ref": "#/components/schemas/ExpandedTest" } }, "required": ["test"] } } } }, "400": { "description": "Malformed id (not a UUID).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test not found (missing, not visible, or foreign workspace — non-disclosing).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tests/{id}/runs": { "get": { "tags": ["Runs"], "summary": "List a Test's past Runs, newest first, filterable by outcome", "description": "Bearer `atc:read` (or cookie session). Mirrors `GET /api/v1/runs/{id}`; the PAT scope catalog has no run-read scope, so run history reuses the read scope. One SECURITY DEFINER RPC resolves the Test's workspace and re-checks ACTIVE membership in-band; any role reads, viewers included. Returns TERMINAL Runs only (`passed` / `failed` / `aborted`) ordered `started_at desc, id desc` — an in-progress Run is neither listed nor counted. Pagination is KEYSET, not offset: `next_cursor` encodes the `(started_at, id)` of the last row, so a Run landing mid-scroll can neither skip nor duplicate a row, and the `id` tie-break keeps runs sharing a `started_at` in a stable total order. The `outcome` filter composes with pagination server-side — page 2 of a filtered history contains only that outcome. `totals` is all-time and filter-invariant. A Test with no terminal Runs returns an empty `items` with zeroed `totals` (never a 404).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "The Test whose Run history to read." }, { "name": "outcome", "in": "query", "required": false, "schema": { "type": "string", "enum": ["passed", "failed", "aborted"] }, "description": "Narrow the list to one terminal outcome. `running` is NOT an accepted value (422) — an in-progress Run is not an outcome." }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 50 }, "description": "Page size, 1..50 (default 50). Out of range is rejected (422); the RPC additionally clamps for direct callers." }, { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Opaque page token taken verbatim from the previous response's `next_cursor`. The token is base64url and therefore URL-safe as issued — it needs no escaping and must not be re-encoded. A malformed token returns 400 — it never silently falls back to the first page." } ], "responses": { "200": { "description": "One page of Run history (possibly empty).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RunHistoryPage" } } } }, "400": { "description": "Malformed Test id (not a UUID) or an undecodable `cursor` (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test not found (also returned for a Test outside the caller's workspaces — no existence leak).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — `outcome` outside passed/failed/aborted (incl. `running`), or `limit` outside 1..50 (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tests/{id}/tags": { "put": { "tags": ["Tests"], "summary": "Assign / replace the tag set on a Test", "description": "Bearer `atc:write` (or cookie session). Replaces the Test's entire tag set (PUT semantics — an empty array clears all tags). One SECURITY DEFINER RPC enforces the write gate, normalization (trim, reserved-lowercase, dedupe), shape rules (≤ 20 tags, ≤ 50 chars each, comma-free), optimistic lock, and no-op detection. Submitting the current set is a 200 no-op — no version bump, no `updated_at` change, no event. A real change emits a single `test.tags_changed` activity-log event with `old_tags` / `new_tags`.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "X-If-Match", "in": "header", "required": false, "schema": { "type": "string" }, "description": "Current Test version for optimistic locking (lenient — absent skips the guard). A mismatch returns 409 with `details.current_version` and `details.current_tags`. Uses a custom header instead of RFC 7232 `If-Match` because the Vercel edge rewrites `If-Match` to 412 (BK-96); `If-Match` is accepted as a fallback off-Vercel." } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestTagsBody" } } } }, "responses": { "200": { "description": "Tags set (or a no-op when unchanged). Returns the expanded Test, now carrying `tags`.", "content": { "application/json": { "schema": { "type": "object", "properties": { "test": {} } } } } }, "400": { "description": "Malformed id, body, or X-If-Match (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a workspace member with write access (`forbidden`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Test not found / not visible — non-disclosing (`not_found`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Version conflict (`conflict`, `details.reason: version_conflict`, with `current_version` + `current_tags`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "A tag exceeds 50 chars, contains a comma, or more than 20 tags supplied (`validation_failed`, `details.reason: tags_invalid`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tests": { "post": { "tags": ["Tests"], "summary": "Create a Test by chaining ATCs", "description": "Bearer `atc:write` (or cookie session). Transactional create across tests + test_steps + activity log via one SECURITY DEFINER RPC. The Test binds to the workspace resolved at the submit instant: explicit `workspace_id` wins, else the cookie session's active workspace; token-authenticated callers must send `workspace_id`. Chain order follows the `atc_ids` array; duplicates are preserved.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "Idempotency-Key", "in": "header", "required": true, "schema": { "type": "string", "pattern": "^[\\w-]{8,128}$" }, "description": "Required. 8–128 chars, [a-zA-Z0-9_-]. A replay with the same key and payload returns the stored 201 snapshot; the same key with a different payload returns 409 `conflict`; a concurrent request with the same key while the first is still in flight also returns 409 `conflict`. Window: 24h." } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TestCreateBody" } } } }, "responses": { "201": { "description": "Test created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "test": { "$ref": "#/components/schemas/Test" } }, "required": ["test"] } } } }, "400": { "description": "Malformed body or missing/invalid Idempotency-Key (`bad_request`, `idempotency_key_required`, `idempotency_key_invalid`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a workspace member with write access.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "One or more selected ATCs are not available in this workspace (`not_found`). Foreign-workspace and nonexistent ids return byte-identical responses — no existence disclosure.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Idempotency-Key reused with a different request payload, or a request with the same key is still in flight (`conflict`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (`validation_failed` for title/uuid/workspace_id rules, `chain_empty` when the chain references no ATC).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["Tests"], "summary": "List Tests carrying a tag", "description": "Bearer `atc:read` (or cookie session). Returns the actor's workspace Tests that carry the given `tag` via a GIN containment match. Results are restricted to the actor's active workspace memberships — caller scope is ignored, cross-workspace Tests never leak. Zero matches return `{ items: [] }`, never a 404. The MVP exposes single-tag filtering only.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "tag", "in": "query", "required": true, "schema": { "type": "string" }, "description": "Required. The single tag to filter by. Normalized like stored tags (reserved tags lowercased), so `Smoke` matches `smoke`. An unused tag returns an empty list, never a 404." } ], "responses": { "200": { "description": "The matching Tests (possibly empty).", "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/FilteredTest" } } }, "required": ["items"] } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "The Bearer PAT does not carry the `atc:read` scope (`forbidden`). The gateway enforces the capability before the handler runs. Cookie sessions hold every capability and never hit this.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Missing `tag` query parameter (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tests/search": { "get": { "tags": ["Tests"], "summary": "Search Tests by title and tags", "description": "Bearer `atc:read` (or cookie session). Project-scoped substring search over Test title and tags. A Test has no project_id of its own (Tests are workspace-scoped); the project match is derived from the Test's chained ATCs. Results are restricted to the caller's active workspace memberships AND to the required `project_id`. Zero matches return an empty `items` array (never 404).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "schema": { "type": "string", "minLength": 1, "description": "Free-text query matched (substring, case-insensitive) against Test title and tags." }, "required": true, "description": "Free-text query matched (substring, case-insensitive) against Test title and tags.", "name": "query", "in": "query" }, { "schema": { "type": "string", "format": "uuid", "description": "Required. Scopes the search to a single project, derived from each Test's chained ATCs; a project outside the caller's active workspaces returns no rows." }, "required": true, "description": "Required. Scopes the search to a single project, derived from each Test's chained ATCs; a project outside the caller's active workspaces returns no rows.", "name": "project_id", "in": "query" }, { "schema": { "type": "integer", "minimum": 1, "maximum": 50, "description": "1..50, default 20." }, "required": false, "description": "1..50, default 20.", "name": "limit", "in": "query" } ], "responses": { "200": { "description": "Matches (possibly empty), newest first.", "content": { "application/json": { "schema": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/TestSearchResult" } } }, "required": ["items"] } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (empty/missing query, missing/invalid project_id, bad limit).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tokens/{id}": { "delete": { "tags": ["Tokens"], "summary": "Revoke a personal access token", "description": "Soft-delete: sets `revoked_at = now()` so the row stays in the audit trail. The bearer middleware rejects revoked rows.", "security": [ { "cookieAuth": [] } ], "parameters": [ { "schema": { "type": "string", "format": "uuid", "description": "Token id from `GET /api/v1/tokens`." }, "required": true, "description": "Token id from `GET /api/v1/tokens`.", "name": "id", "in": "path" } ], "responses": { "204": { "description": "Token revoked." }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "A Bearer PAT was used. This route is session-only (`auth: 'cookie-only'`): a token cannot revoke tokens, including itself. Sign in through the browser and retry with the session cookie.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Token not found or already revoked.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/tokens": { "post": { "tags": ["Tokens"], "summary": "Issue a personal access token", "description": "Issuance is session-authenticated — a logged-in user mints PATs via the web app. PATs cannot mint other PATs (chicken-and-egg). The raw secret is returned exactly once.", "security": [ { "cookieAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateTokenBody" } } } }, "responses": { "201": { "description": "Token created. Capture the `token` field — it is unrecoverable.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateTokenResponse" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: a Personal Access Token was used (this route is session-only — a PAT must not mint a PAT); `workspace:admin` was requested without a `workspace_id`; the caller is not an ACTIVE member of the target workspace; or the caller is a member but not `admin`/`owner` while requesting `workspace:admin`. All four are checked after body validation. See ADR-0005.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Body failed validation.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["Tokens"], "summary": "List the caller's tokens", "description": "Returns every PAT the calling user owns, without the secret hash. RLS enforces ownership.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "responses": { "200": { "description": "Token list.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ListTokensResponse" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/user-stories/{id}": { "get": { "tags": ["User Stories"], "summary": "Read a user story", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Story.", "content": { "application/json": { "schema": { "type": "object", "properties": { "user_story": { "$ref": "#/components/schemas/UserStory" } }, "required": ["user_story"] } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "patch": { "tags": ["User Stories"], "summary": "Edit a user story", "description": "Bearer `atc:write` (or cookie session). Member-only. Title / description / Jira key. The Jira key is immutable once set (409 on change).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserStoryUpdateBody" } } } }, "responses": { "200": { "description": "Updated.", "content": { "application/json": { "schema": { "type": "object", "properties": { "user_story": { "$ref": "#/components/schemas/UserStory" } }, "required": ["user_story"] } } } }, "400": { "description": "Malformed id or body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Duplicate/immutable Jira key, or ready-to-test gate (no active acceptance criteria).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "delete": { "tags": ["User Stories"], "summary": "Soft-delete (archive) a user story", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Archived.", "content": { "application/json": { "schema": { "type": "object", "properties": { "user_story": { "$ref": "#/components/schemas/UserStory" } }, "required": ["user_story"] } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope or not a member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Already archived.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/active-runs": { "get": { "tags": ["Workspaces"], "summary": "List the runs currently in progress across a whole workspace, with their step progress", "description": "Bearer `atc:read` (or cookie session) — rows carry project names, run identifiers and executor identities, so it is gated like the sibling workspace inventory read. `run:execute` is deliberately NOT the gate: it is a write capability. Active means `runs.status = 'running'`; finished and aborted runs are excluded, and a blocked run is still running (there is no `blocked` run status — blocking lives on the steps, and is surfaced per row as `state`). Runs entirely under the caller's own RLS, so a foreign, nonexistent, or lost-membership workspace id returns the SAME empty `200` an idle workspace does — never an existence echo. A read that FAILS answers 500, never an empty list.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 }, "description": "How many runs to return, 1..20 (default 5 — the Home widget's page size). Out of range is rejected with 422, never silently clamped. Does not affect `active_count`." } ], "responses": { "200": { "description": "The workspace's active runs (possibly empty) and the exact count of them.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActiveRuns" } } } }, "400": { "description": "The workspace id in the path is not a UUID (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "`limit` is not an integer in 1..20 (`validation_failed`, `details.reason = limit_out_of_range`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "The rollup could not be read (`internal_error`). Deliberately not collapsed into an empty list.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/billing/checkout/cancel": { "post": { "tags": ["Billing"], "summary": "Cancel the workspace's open checkout session", "description": "Owner-only. Expires the Stripe Checkout Session server-side (best-effort) and releases the one-open-session lock immediately, instead of stranding the owner for the session's TTL. A no-op (still 204) when there is no open session.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "204": { "description": "Canceled (or nothing to cancel)." }, "400": { "description": "Malformed workspace id (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: missing `workspace:admin` capability / PAT not bound to this workspace (ADR-0006); or the caller is not the workspace OWNER (`not_workspace_owner` — stricter than admin, checked inside the handler).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The open session has already been PAID and is being activated (`checkout_in_progress`, `details.reason: checkout_already_completed`). The row is deliberately left open so the Stripe webhook still applies the upgrade — retry the cancel only if the upgrade does not land.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "The payment processor could not be reached to check the session, or the session row could not be read or written (`internal_error`). The one-open-session lock is deliberately NOT released on this path — cancelling a session that may already have been paid is worse than refusing.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "503": { "description": "The payment processor is unavailable in this environment (`payment_processor_unavailable`). Generic client message; the missing configuration is logged server-side only.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/billing/checkout": { "post": { "tags": ["Billing"], "summary": "Start a self-serve Community -> Cloud upgrade", "description": "Owner-only (bunkai_is_workspace_owner) — an admin/member/viewer is rejected before any Stripe call. `Idempotency-Key` is REQUIRED (ADR-0002). Plan activation happens asynchronously via the Stripe webhook once payment is confirmed — this endpoint never writes `workspaces.plan` itself. At most one open Checkout Session may exist per workspace at a time (a partial unique index backs this); a second concurrent request either reuses the existing session's URL or answers 409.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "Idempotency-Key", "in": "header", "required": true, "schema": { "type": "string", "pattern": "^[\\w-]{8,128}$" }, "description": "Required. 8–128 chars, [a-zA-Z0-9_-]. A replay with the same key and payload returns the stored response; the same key with a different payload returns 409 `conflict`. This is the HTTP-level replay guard — distinct from the DB-level one-open-session-per-workspace guard that covers two different tabs/keys racing for the same workspace." } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BillingCheckoutBody" } } } }, "responses": { "200": { "description": "Checkout Session URL (freshly created, or replayed/reused).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BillingCheckoutResponse" } } } }, "400": { "description": "Missing/malformed Idempotency-Key, or malformed workspace id (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: the PAT lacks the `workspace:admin` capability (`details.reason: missing_capability`); the PAT is not bound to this workspace (ADR-0006); or the caller is not the workspace OWNER (`details.reason: not_workspace_owner`, stricter than admin). A workspace that does not exist answers this same `not_workspace_owner` response (identical status and body), so the endpoint never discloses whether a workspace id exists. A PAT from headless sign-in never carries `workspace:admin` (ADR-0005): an owner automating checkout mints a workspace-scoped token via `POST /api/v1/tokens` with `workspace_id` set to this workspace and `scopes` including `workspace:admin`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Workspace billing overview unavailable after the owner check passed (not reachable for a workspace the caller does not own, or one that does not exist: those answer 403 `not_workspace_owner`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A checkout session is already open (`checkout_in_progress`) or an Idempotency-Key was reused with a different payload (`conflict`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Workspace is already on Cloud/Enterprise (`plan_not_upgradable`), or `seat_quantity` is out of bounds (`seat_quantity_invalid`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "502": { "description": "The payment processor rejected or failed the Checkout Session request (`upstream_error`). Generic client message; the upstream detail is logged server-side only. The Idempotency-Key is not stored on this failure, but Stripe's own request idempotency is keyed on it, so a retry must use a NEW Idempotency-Key.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "503": { "description": "The payment processor is unavailable in this environment (`payment_processor_unavailable`). Generic client message; which configuration is missing is logged server-side only. Returned before any checkout session row is created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/billing": { "get": { "tags": ["Workspaces"], "summary": "Read a workspace's plan, seats, and usage overview", "description": "Bearer `atc:read` (or cookie session) — owner/admin only. Same scope floor as the sibling workspace-inventory reads (`/coverage`, `/open-bugs`, `/recent-projects`, `/active-runs`); cookie sessions hold the full capability set, so this constrains PATs only. `bunkai_workspace_billing_overview` is SECURITY INVOKER with no caller-supplied actor parameter; its own step-0 gate (`bunkai_is_workspace_admin`) restricts the read, and this route calls it through the caller's own RLS-scoped client, never a service-role client. A non-admin caller, an unknown workspace id, and a workspace the caller cannot see all collapse into the SAME `404 not_found` — never `403`, so the response never discloses more than the workspace's existence. The tier ladder (limits, price, display name) is NOT part of this payload; look it up client-side in `lib/billing/plan-tiers.ts` keyed by the returned `plan`.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "The workspace's billing overview.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceBillingOverview" } } } }, "400": { "description": "The workspace id in the path is not a UUID (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Not found — returned uniformly for a non-admin caller, an unknown workspace, and a workspace the caller cannot see (`not_found`, never `403`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "The overview could not be read (`internal_error`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/coverage": { "get": { "tags": ["Workspaces"], "summary": "Summarize test coverage across a whole workspace", "description": "Bearer `atc:read` (or cookie session) — coverage is a workspace-wide inventory of test assets, the same class of read the sibling Home endpoints (`/recent-projects`, `/active-runs`, `/open-bugs`) are gated on. Computed by summing `bunkai_report_project_coverage` — the SAME rollup behind `GET /api/v1/projects/{id}/coverage` and the project Metrics screen — over every project in the workspace, so the two endpoints can never disagree about whether an acceptance criterion is covered. The workspace figure is AC-weighted (`sum(ac_bound) / sum(ac_total)`), not an average of per-project percentages. The shortfall is reported as TWO separate figures on purpose: `ac_not_run` (test cases bound, awaiting execution) and `ac_uncovered` (nothing bound at all) are different problems and a single percentage conflates them. Execution state is POINT-IN-TIME, not cumulative — each ATC counts by its most recent run only, and joining a new run resets it to pending — so `ac_executed` and `executed_coverage_percent` fall when a regression run opens and are not a history of what has ever been tested; `ac_coverage_percent` is the figure that only moves when coverage itself does. A successful response may be up to 60 seconds stale (the rollup is memoized per workspace to keep this off the hot path of every landing-page load); adding or removing a project invalidates it at once. NO trend or prior-period delta is returned: nothing in the schema records when an ATC was bound to an acceptance criterion or when a step was executed, so a historical coverage figure cannot be sourced without inventing one. Runs entirely under the caller's own RLS, so a foreign, nonexistent, or lost-membership workspace id returns the SAME zeroed `200` (with null percentages) that an empty workspace does — never an existence echo. A read that FAILS answers 500, never zeroes.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "The workspace's coverage rollup. Percentages are null when the workspace holds no acceptance criteria.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceCoverage" } } } }, "400": { "description": "The workspace id in the path is not a UUID (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "The rollup could not be read (`internal_error`). Deliberately not collapsed into zeroes — one unreadable project fails the whole figure rather than silently shrinking the denominator. Also returned, for the same reason, when the workspace holds more projects than one pass may roll up (60): a percentage over some of a workspace's projects is a wrong number that looks like a right one, so the endpoint refuses instead of approximating.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/data-export/download": { "get": { "tags": ["Workspaces"], "summary": "Download the ready export archive", "description": "Owner-only, cookie session only. Streams the ZIP archive of the latest completed, unexpired export. Records an export.downloaded Activity Stream entry on every successful call.", "security": [ { "cookieAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "ZIP archive stream.", "content": { "application/zip": { "schema": { "type": "string", "format": "binary" } } } }, "400": { "description": "Malformed workspace id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Not the workspace Owner, or a Bearer PAT was used.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "No export requested yet, not ready, or the ready archive has expired.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/data-export": { "post": { "tags": ["Workspaces"], "summary": "Request an export of this workspace's data", "description": "Owner-only, cookie session only (a Bearer PAT is rejected regardless of scope). Enqueues an async export and processes it in the background. At most one active export per workspace (409). Returns immediately with the job id.", "security": [ { "cookieAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "202": { "description": "Export enqueued.", "content": { "application/json": { "schema": { "type": "object", "properties": { "export_job_id": { "type": "string", "format": "uuid" }, "status": { "type": "string" } }, "required": ["export_job_id", "status"] } } } }, "400": { "description": "Malformed workspace id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Not the workspace Owner, or a Bearer PAT was used.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "An export is already being prepared for this workspace.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["Workspaces"], "summary": "Poll the latest export for this workspace", "description": "Owner-only, cookie session only. Returns the latest export request, or null if none was ever requested. The client derives \"expired\" by comparing expires_at to now.", "security": [ { "cookieAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Latest export (or null).", "content": { "application/json": { "schema": { "type": "object", "properties": { "export": { "$ref": "#/components/schemas/WorkspaceExport" } }, "required": ["export"] } } } }, "400": { "description": "Malformed workspace id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Not the workspace Owner, or a Bearer PAT was used.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/invites/{inviteId}": { "post": { "tags": ["Invites"], "summary": "Rotate (resend) a workspace invite token", "description": "Generates a new secret + extends expiry by 7 days. Clears any prior acceptance/revocation. Admin/owner only via RLS.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "inviteId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Rotated invite.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceInviteResendResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: the Bearer PAT lacks the `workspace:admin` capability; the PAT is not bound to any workspace (there is no global admin — ADR-0005); or the PAT is bound to a DIFFERENT workspace than `{id}`. The token-binding checks run before RLS, so a correctly-scoped RLS row does not rescue a mis-bound token. See ADR-0006.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Invite not found or no permission.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "delete": { "tags": ["Invites"], "summary": "Revoke a workspace invite", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "inviteId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Revoked.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceInviteDeleteResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: the Bearer PAT lacks the `workspace:admin` capability; the PAT is not bound to any workspace (there is no global admin — ADR-0005); or the PAT is bound to a DIFFERENT workspace than `{id}`. The token-binding checks run before RLS, so a correctly-scoped RLS row does not rescue a mis-bound token. See ADR-0006.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Invite not found or no permission.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/invites": { "post": { "tags": ["Invites"], "summary": "Issue a workspace invite", "description": "Admin/owner only. Generates a one-time token; returns the raw token + accept_url in the body. MVP does not send an email — share the URL out-of-band.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceInviteCreateBody" } } } }, "responses": { "201": { "description": "Invite created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceInviteCreateResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: the Bearer PAT lacks the `workspace:admin` capability; the PAT is not bound to any workspace (there is no global admin — ADR-0005); the PAT is bound to a DIFFERENT workspace than `{id}`; or the caller is not an admin/owner. The token-binding checks run before RLS, so a correctly-scoped RLS row does not rescue a mis-bound token. See ADR-0006.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Email already belongs to an active member (`email_already_member`) or a pending invite exists (`invite_already_pending`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["Invites"], "summary": "List workspace invites", "description": "Requires the `workspace:admin` capability; RLS then limits results to admins/owners of the workspace. A Bearer PAT must additionally be bound to THIS workspace.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Invite list.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceInviteListResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: the Bearer PAT lacks the `workspace:admin` capability; the PAT is not bound to any workspace (there is no global admin — ADR-0005); or the PAT is bound to a DIFFERENT workspace than `{id}`. The token-binding checks run before RLS, so a correctly-scoped RLS row does not rescue a mis-bound token. See ADR-0006.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/membership": { "delete": { "tags": ["Workspaces"], "summary": "Leave a workspace", "description": "Removes the caller's own active membership row. Session-only — Personal Access Tokens cannot leave a workspace. Blocked server-side when the caller has no other active membership, or is the workspace's only active owner. On success, the caller's workspace-scoped PATs for the left workspace are revoked. If the left workspace was the caller's active one, the response carries the newly re-resolved active workspace (oldest remaining membership first) and the `bk_active_ws` cookie is rotated in the same response.", "security": [ { "cookieAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Left the workspace.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceLeaveResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Caller authenticated via a Personal Access Token.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Workspace not found or caller is not an active member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Caller has no other active membership (`last_membership`), or is the workspace's only active owner (`sole_owner`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/notifications/read-all": { "post": { "tags": ["Notifications"], "summary": "Mark every visible unread notification read, for one workspace", "description": "Marks all of the caller's own unread notifications in one workspace read. Plain RLS-scoped bulk update, no RPC — `notifications_update_recipient_member` (migration 0053_notifications.sql) is the entire authorization surface. Scoped to exactly ONE workspace (the path `id`) — never cross-workspace, per the PO-ratified business rule. Idempotent: a repeat call updates zero rows and still returns 200 with `updated_count: 0`.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Every visible unread notification in this workspace is now marked read.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarkAllNotificationsReadResponse" } } } }, "400": { "description": "The workspace id is not a UUID (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/notifications": { "get": { "tags": ["Notifications"], "summary": "List the caller's notification inbox for one workspace, newest first", "description": "Cookie session or Bearer PAT; no scope requirement — a personal read, not a `workspace:admin` operation. `bunkai_list_notifications` is SECURITY INVOKER — it runs under the caller's own RLS, so a foreign/inaccessible workspace id silently returns an empty page rather than leaking existence. Pagination is KEYSET on `(created_at desc, id desc)`. `unread_count` is independent of pagination. An empty `items` is always a valid 200 — this endpoint never answers 404.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 30 }, "description": "Page size, 1..50 (default 30). Out of range is rejected (422); the RPC additionally clamps for direct callers." }, { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Opaque page token taken verbatim from the previous response's `next_cursor`. A malformed token returns 400 — it never silently falls back to the first page." } ], "responses": { "200": { "description": "One page of the notification inbox (possibly empty).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotificationsPage" } } } }, "400": { "description": "An undecodable `cursor` (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed — workspace id not a UUID, or `limit` outside 1..50 (`validation_failed`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/open-bugs": { "get": { "tags": ["Workspaces"], "summary": "Count the open bugs across a whole workspace, broken down by severity", "description": "Bearer `atc:read` (or cookie session) — the response is a workspace-wide defect posture, the same class of workspace inventory the sibling Home reads (`/recent-projects`, `/active-runs`) are gated on. OPEN means UNRESOLVED: `bugs.status in ('open', 'in_progress')`, the two states before a fix exists; `resolved` and `closed` are excluded. The counted statuses are echoed back as `open_statuses` so the definition travels with the numbers. Bugs filed against an ARCHIVED module are excluded, the same rule the bug lists and the defect heatmap apply, so this figure never claims defects a caller cannot enumerate. `open_count` is the sum of `by_severity` by construction, never an independent count, so the total always reconciles with the breakdown. Runs entirely under the caller's own RLS, so a foreign, nonexistent, or lost-membership workspace id returns the SAME zeroed `200` a workspace with no open bugs does — never an existence echo. A read that FAILS answers 500, never zeroes.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "The workspace's open-bug count and its severity breakdown (possibly all zero).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OpenBugs" } } } }, "400": { "description": "The workspace id in the path is not a UUID (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "The rollup could not be read (`internal_error`). Deliberately not collapsed into zeroes.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/projects": { "post": { "tags": ["Projects"], "summary": "Create a project in a workspace", "description": "Bearer `atc:write` (or cookie session). Member-only (role >= member). Auto-derives a per-workspace-unique slug from the name. Duplicate slug returns 409; non-members return 403.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectCreateBody" } } } }, "responses": { "201": { "description": "Project created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProjectCreateResponse" } } } }, "400": { "description": "Malformed workspace id or invalid JSON body.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:write scope, or caller is not a member of the workspace.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "A project with this slug already exists in the workspace.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed (including names that map to a reserved slug).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/recent-projects": { "get": { "tags": ["Workspaces"], "summary": "List a workspace's projects by most recent activity, with their module and ATC counts", "description": "Bearer `atc:read` (or cookie session) — every row carries an exact per-project ATC count, so it is gated like the other ATC reads. Runs entirely under the caller's own RLS, so a foreign, nonexistent, or lost-membership workspace id returns the SAME `200 {\"projects\": []}` an empty workspace does — never an existence echo. A read that FAILS answers 500, never an empty list, so a caller can always tell a quiet workspace from a broken one.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 20, "default": 5 }, "description": "How many projects to return, 1..20 (default 5 — the Home widget's page size). Out of range is rejected with 422, never silently clamped." } ], "responses": { "200": { "description": "The workspace's most recently active projects (possibly empty).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecentProjects" } } } }, "400": { "description": "The workspace id in the path is not a UUID (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Not authenticated.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "`limit` is not an integer in 1..20 (`validation_failed`, `details.reason = limit_out_of_range`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "500": { "description": "The rollup could not be read (`internal_error`). Deliberately not collapsed into an empty list.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}/restore": { "post": { "tags": ["Workspaces"], "summary": "Restore a workspace during its grace period", "description": "Owner-only, session-only (BK-512, ADR-0015 point 10). Clears `deleted_at` on a soft-deleted workspace — no data movement. Reachable from the confirm-time deletion-receipt email even though the workspace is invisible via RLS while deleted, since the underlying RPC resolves ownership directly and bypasses RLS internally.", "security": [ { "cookieAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Workspace restored.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceRestoreResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Caller authenticated via a Personal Access Token, or is an active member but not the workspace Owner.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Workspace not found, already purged, or caller is not an active member.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "The workspace is not currently deleted (`not_deleted`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces": { "post": { "tags": ["Workspaces"], "summary": "Create workspace + auto-enrol caller as owner", "description": "Wraps the `bunkai_bootstrap_workspace` SECURITY DEFINER RPC so the workspace row and the owner-membership row are inserted in a single transaction.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceCreateBody" } } } }, "responses": { "201": { "description": "Workspace created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceCreateResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "409": { "description": "Slug already in use.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Request body failed validation.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "get": { "tags": ["Workspaces"], "summary": "List workspaces the caller belongs to", "description": "Bearer `atc:read` (or cookie session). RLS-filtered list of every workspace where the caller has an active membership.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "responses": { "200": { "description": "Workspace list.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceListResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } }, "/api/v1/workspaces/{id}": { "get": { "tags": ["Workspaces"], "summary": "Get a single workspace", "description": "Bearer `atc:read` (or cookie session).", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Workspace.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Missing atc:read scope.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Workspace not found or not visible.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "patch": { "tags": ["Workspaces"], "summary": "Update workspace metadata", "description": "Owner-only. Today only `name` is mutable; slug rotation is post-MVP.", "security": [ { "cookieAuth": [] }, { "bearerAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspacePatchBody" } } } }, "responses": { "200": { "description": "Updated workspace.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceResponse" } } } }, "400": { "description": "The body was not valid JSON, the path `{id}` is not a UUID, or the body was an empty object `{}` — at least one updatable field is required (`bad_request`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Any of: the Bearer PAT lacks the `workspace:admin` capability; the PAT is not bound to any workspace (there is no global admin — ADR-0005); the PAT is bound to a DIFFERENT workspace than `{id}`; or the caller is not an owner (RLS returns zero rows). The token-binding checks run before RLS, so a correctly-scoped RLS row does not rescue a mis-bound token. See ADR-0006.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "422": { "description": "Validation failed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } }, "delete": { "tags": ["Workspaces"], "summary": "Delete a workspace I own", "description": "Owner-only, session-only (BK-512, ADR-0015). Soft-deletes the workspace: access ends immediately for the owner and every other member, the workspace's Personal Access Tokens and pending invites are revoked in the same transaction, and a deletion audit record is written. Nothing is physically removed until the 30-day grace period elapses, during which the workspace can be restored via `POST /api/v1/workspaces/{id}/restore`. A confirmation email naming the workspace, the actor and the restore deadline is sent to the owner and every active member. If the deleted workspace was the caller's active one, the response carries the newly re-resolved active workspace (oldest remaining membership first) and the `bk_active_ws` cookie is rotated in the same response.", "security": [ { "cookieAuth": [] } ], "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } } ], "responses": { "200": { "description": "Workspace deleted (soft-delete; grace period started).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceDeleteResponse" } } } }, "401": { "description": "Caller is not signed in.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "403": { "description": "Caller authenticated via a Personal Access Token, or is an active member but not the workspace Owner.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "404": { "description": "Workspace not found, caller is not an active member, or the workspace was already deleted by a concurrent request (Scenario N5 — a double-submit's losing call is refused as not_found, matching the shipped `DELETE /api/v1/tokens/{id}` precedent, not a 409).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } } }, "webhooks": {} }