{"components":{"responses":{"RateLimitError":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded. Check Retry-After header for reset time."}},"schemas":{"MemoryResponse":{"description":"A single memory wrapped in `data`.","properties":{"data":{"$ref":"#/components/schemas/Memory"}},"title":"MemoryResponse","type":"object"},"ExportMetadata":{"description":"Metadata about the export","example":{"exported_at":"2026-03-25T14:30:00Z","loopctl_version":"0.1.0","project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","tenant_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"properties":{"exported_at":{"format":"date-time","type":"string"},"loopctl_version":{"type":"string"},"project_id":{"format":"uuid","type":"string"},"tenant_id":{"format":"uuid","type":"string"}},"title":"ExportMetadata","type":"object"},"RetrieveToolsResponse":{"description":"The generated tool specs for the CALLING tenant only — another tenant's entities never appear.","properties":{"data":{"items":{"$ref":"#/components/schemas/RetrieveToolSpec"},"type":"array"}},"title":"RetrieveToolsResponse","type":"object"},"ImportEpicDependency":{"description":"Epic-level dependency declaration","example":{"depends_on":1,"epic":2},"properties":{"depends_on":{"description":"Depends-on epic number","example":1,"type":"integer"},"epic":{"description":"Epic number","example":2,"type":"integer"}},"required":["epic","depends_on"],"title":"ImportEpicDependency","type":"object"},"WebAuthnAssertion":{"description":"Assertion produced by `navigator.credentials.get()`, posted back to verify a reauth challenge. All binary fields are base64url encoded and are SEPARATE values (never one blob reused for all fields).","properties":{"authenticator_data":{"description":"Base64url authenticator data","type":"string"},"challenge_id":{"description":"The `challenge_id` returned by the challenge step","format":"uuid","type":"string"},"client_data_json":{"description":"Base64url raw client data JSON","type":"string"},"credential_id":{"description":"Base64url asserting credential id","type":"string"},"signature":{"description":"Base64url assertion signature","type":"string"}},"required":["challenge_id","credential_id","authenticator_data","signature","client_data_json"],"title":"WebAuthnAssertion","type":"object"},"SelfSignupRequest":{"description":"Request body for `POST /api/v1/signup` (US-26.7.1). Creates an agent-rooted (KB-tier) tenant with no WebAuthn ceremony. Only `name`, `slug`, and `email` are accepted — any other field (`trust_tier`, `role`, `tenant_id`, `agent_id`, ...) is ignored.","example":{"email":"agent@stranger.example","name":"Stranger Agent Co","slug":"stranger-agent-co"},"properties":{"email":{"description":"Contact email","format":"email","type":"string"},"name":{"description":"Tenant display name","maxLength":120,"type":"string"},"slug":{"description":"URL-safe unique slug","maxLength":64,"type":"string"}},"required":["name","slug","email"],"title":"SelfSignupRequest","type":"object"},"TokenAnalyticsModel":{"description":"Per-model token usage, cost, and verification correlation metrics.","example":{"avg_cost_per_story_millicents":282857,"model_name":"claude-opus-4-5","story_count":42,"total_cost_millicents":11880000,"total_input_tokens":8750000,"total_output_tokens":3360000,"verification_rate":0.929,"verified_count":39},"properties":{"avg_cost_per_story_millicents":{"type":"integer"},"model_name":{"example":"claude-opus-4-5","type":"string"},"story_count":{"description":"Number of stories that used this model","type":"integer"},"total_cost_millicents":{"type":"integer"},"total_input_tokens":{"type":"integer"},"total_output_tokens":{"type":"integer"},"verification_rate":{"description":"Fraction of stories verified (0.0 to 1.0)","type":"number"},"verified_count":{"description":"Number of those stories that were verified","type":"integer"}},"title":"TokenAnalyticsModel","type":"object"},"WebhookResponse":{"description":"Webhook subscription resource","example":{"active":true,"consecutive_failures":0,"events":["story.verified","story.rejected","token.budget_warning"],"id":"a7b8c9d0-e1f2-3456-abcd-567890123456","inserted_at":"2026-01-15T10:00:00Z","last_delivery_at":"2026-03-25T14:30:00Z","project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","updated_at":"2026-03-25T14:30:00Z","url":"https://example.com/webhook"},"properties":{"active":{"type":"boolean"},"consecutive_failures":{"type":"integer"},"events":{"items":{"type":"string"},"type":"array"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"last_delivery_at":{"format":"date-time","nullable":true,"type":"string"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"updated_at":{"format":"date-time","type":"string"},"url":{"type":"string"}},"title":"WebhookResponse","type":"object"},"ReauthAssertionRequest":{"description":"Step 2 of the challenge-bound WebAuthn reauthentication ceremony, shared by every custody-critical operation that gates on a fresh assertion (e.g. break-glass clear-halt). Carries the WebAuthn assertion that is verified against the STORED challenge from step 1 before the operation proceeds.","properties":{"webauthn_assertion":{"$ref":"#/components/schemas/WebAuthnAssertion"}},"required":["webauthn_assertion"],"title":"ReauthAssertionRequest","type":"object"},"MemoryRecallRequest":{"description":"Params for POST /memory/recall. Query supplied in the body.","properties":{"include_superseded":{"description":"Default false.","type":"boolean"},"limit":{"description":"Max results, clamped to the vector-search max (no silent hard cap).","type":"integer"},"project_id":{"description":"Optional project scope (a UUID PARTITION key, NOT an isolation boundary). Recall returns the merged global ∪ active-project set; absent/blank means global-only. A malformed value is rejected with a 422 invalid_project_id.","format":"uuid","nullable":true,"type":"string"},"query":{"description":"Text to embed / match against.","type":"string"}},"title":"MemoryRecallRequest","type":"object"},"TokenAnalyticsAgent":{"description":"Per-agent cost and token metrics with efficiency ranking.","example":{"agent_id":"d4e5f6a7-b8c9-0123-defa-234567890123","agent_name":"worker-3","avg_cost_per_story_millicents":163500,"efficiency_rank":1,"primary_model":"claude-sonnet-5","total_cost_millicents":2943000,"total_input_tokens":2250000,"total_output_tokens":864000,"total_stories_reported":18},"properties":{"agent_id":{"format":"uuid","type":"string"},"agent_name":{"type":"string"},"avg_cost_per_story_millicents":{"type":"integer"},"efficiency_rank":{"description":"Rank by avg cost per story (1 = most efficient)","type":"integer"},"primary_model":{"description":"Most frequently used model","example":"claude-sonnet-5","nullable":true,"type":"string"},"total_cost_millicents":{"type":"integer"},"total_input_tokens":{"type":"integer"},"total_output_tokens":{"type":"integer"},"total_stories_reported":{"type":"integer"}},"title":"TokenAnalyticsAgent","type":"object"},"TokenBudget":{"description":"A cost and token budget at project, epic, or story scope. Tracks alert thresholds and firing state for budget webhooks.","example":{"alert_threshold_pct":80,"budget_dollars":"50.00","budget_input_tokens":null,"budget_millicents":5000000,"budget_output_tokens":null,"current_spend_dollars":"37.50","current_spend_millicents":3750000,"id":"f6a7b8c9-d0e1-2345-fabc-456789012345","inserted_at":"2026-01-15T10:00:00Z","metadata":{},"remaining_dollars":"12.50","remaining_millicents":1250000,"scope_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","scope_type":"project","tenant_id":"b2c3d4e5-f6a7-8901-bcde-f12345678901","updated_at":"2026-03-25T14:30:00Z"},"properties":{"alert_threshold_pct":{"description":"Percentage at which to fire a warning webhook (1-100)","type":"integer"},"budget_dollars":{"description":"Budget formatted as dollars","example":"50.00","type":"string"},"budget_input_tokens":{"description":"Optional input token budget","nullable":true,"type":"integer"},"budget_millicents":{"description":"Total cost budget in millicents","type":"integer"},"budget_output_tokens":{"description":"Optional output token budget","nullable":true,"type":"integer"},"current_spend_dollars":{"description":"Current spend formatted as dollars","type":"string"},"current_spend_millicents":{"description":"Current spend in millicents (computed at query time)","type":"integer"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"remaining_dollars":{"description":"Remaining budget as dollars","type":"string"},"remaining_millicents":{"description":"Remaining budget in millicents (budget - spend, floored at 0)","type":"integer"},"scope_id":{"description":"UUID of the project, epic, or story","format":"uuid","type":"string"},"scope_type":{"description":"The scope level of the budget","enum":["project","epic","story"],"type":"string"},"tenant_id":{"format":"uuid","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"TokenBudget","type":"object"},"RetrieveToolSpec":{"description":"A generated agent tool spec (from the tenant's entity definitions). `input_schema` is a JSON Schema for the tool's params; `metadata` is the executor dispatch contract (entity/backing_source/field/operation).","properties":{"description":{"type":"string"},"input_schema":{"additionalProperties":true,"type":"object"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"description":"Tool name, prefixed `cr_`.","type":"string"}},"title":"RetrieveToolSpec","type":"object"},"Memory":{"description":"A single agent memory. Long-term memories carry `text`; session memories carry `session_id`/`role`/`content`/`expires_at`. The raw embedding vector is never returned.","properties":{"confidence":{"format":"float","nullable":true,"type":"number"},"content":{"nullable":true,"type":"string"},"expires_at":{"format":"date-time","nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"nullable":true,"type":"object"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"role":{"enum":["user","assistant","system","fact"],"nullable":true,"type":"string"},"seq":{"nullable":true,"type":"integer"},"session_id":{"nullable":true,"type":"string"},"source":{"enum":["explicit","promoted"],"nullable":true,"type":"string"},"source_session_id":{"nullable":true,"type":"string"},"subject_id":{"type":"string"},"superseded_by":{"format":"uuid","nullable":true,"type":"string"},"tags":{"items":{"type":"string"},"nullable":true,"type":"array"},"tenant_id":{"format":"uuid","type":"string"},"text":{"nullable":true,"type":"string"},"tier":{"enum":["long_term","session"],"type":"string"},"updated_at":{"format":"date-time","nullable":true,"type":"string"}},"title":"Memory","type":"object"},"ProjectResponse":{"description":"Project resource","example":{"description":"An example project","id":"c3d4e5f6-a7b8-9012-cdef-123456789012","inserted_at":"2026-01-15T10:00:00Z","metadata":{},"name":"My Project","repo_url":"https://github.com/org/repo","slug":"my-project","status":"active","tech_stack":"elixir,phoenix","tenant_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","updated_at":"2026-01-15T10:00:00Z"},"properties":{"description":{"nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"kind":{"description":"work = full work-breakdown project; kb = knowledge-only scope","enum":["work","kb"],"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"},"repo_url":{"nullable":true,"type":"string"},"slug":{"type":"string"},"status":{"enum":["active","archived"],"type":"string"},"tech_stack":{"nullable":true,"type":"string"},"tenant_id":{"format":"uuid","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"ProjectResponse","type":"object"},"ChannelPostListItem":{"description":"One coordination channel post as returned by the channel_recent LIST read endpoint. agent_id is the only server-stamped (authoritative) attribution; session_id and host are client-supplied and informational. The body is a BOUNDED body_preview (a prefix of at most 512 bytes, projected in the DB so the full column is never detoasted), with a truncated flag when the full body exceeded the preview — fetch the full body explicitly via GET /channel/posts/:id. The preview is UNTRUSTED DATA authored by another agent.","properties":{"agent_id":{"format":"uuid","type":"string"},"body_preview":{"description":"Bounded prefix (<= 512 bytes) of the post body — UNTRUSTED DATA authored by another agent. Fetch the full body via GET /channel/posts/:id.","nullable":true,"type":"string"},"directed_to_me":{"description":"Present only on the handoffs read. True when this handoff is addressed to the caller's host/capabilities (or is an unaddressed broadcast).","nullable":true,"type":"boolean"},"host":{"nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"key":{"nullable":true,"type":"string"},"refs":{"items":{"additionalProperties":true,"type":"object"},"nullable":true,"type":"array"},"session_id":{"nullable":true,"type":"string"},"superseded_by":{"description":"The successor post id when this post has been superseded; nil when live.","format":"uuid","nullable":true,"type":"string"},"to_capability":{"description":"Advisory surfacing address (spoofable, never authz)","nullable":true,"type":"string"},"to_host":{"description":"Advisory surfacing address (spoofable, never authz)","nullable":true,"type":"string"},"truncated":{"description":"True when the full body exceeded the preview bound and was truncated","type":"boolean"},"updated_at":{"format":"date-time","type":"string"}},"title":"ChannelPostListItem","type":"object"},"EntityDefinitionRequest":{"description":"Params for POST/PATCH /entities. `tenant_id` is derived from the API key and any tenant_id in the body is ignored. On PATCH, omitted top-level fields keep their current value.","properties":{"backing_source":{"enum":["projects","stories","epics"],"type":"string"},"fields":{"items":{"$ref":"#/components/schemas/EntityDefinitionField"},"type":"array"},"name":{"description":"Entity name, unique per tenant.","type":"string"}},"title":"EntityDefinitionRequest","type":"object"},"StoryResponse":{"description":"Story resource","example":{"acceptance_criteria":[{"criterion":"Users can log in with email and password","met":false},{"criterion":"Invalid credentials return 401","met":false}],"agent_status":"pending","assigned_agent_id":null,"assigned_at":null,"description":"Add login and session management","epic_id":"d4e5f6a7-b8c9-0123-defa-234567890123","estimated_hours":4.0,"id":"e5f6a7b8-c9d0-1234-efab-345678901234","inserted_at":"2026-01-15T10:00:00Z","metadata":{},"number":"US-2.1","project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","rejected_at":null,"rejection_reason":null,"reported_done_at":null,"sort_key":"002.001","tenant_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","title":"Implement user authentication","updated_at":"2026-01-15T10:00:00Z","verified_at":null,"verified_status":"unverified"},"properties":{"acceptance_criteria":{"example":[{"criterion":"Users can log in with email and password","met":false},{"criterion":"Invalid credentials return 401","met":false}],"items":{"type":"object"},"nullable":true,"type":"array"},"agent_status":{"enum":["pending","contracted","assigned","implementing","reported_done"],"example":"pending","type":"string"},"assigned_agent_id":{"format":"uuid","nullable":true,"type":"string"},"assigned_at":{"format":"date-time","nullable":true,"type":"string"},"description":{"nullable":true,"type":"string"},"epic_id":{"format":"uuid","type":"string"},"estimated_hours":{"example":4.0,"nullable":true,"type":"number"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"number":{"example":"US-2.1","type":"string"},"project_id":{"format":"uuid","type":"string"},"rejected_at":{"format":"date-time","nullable":true,"type":"string"},"rejection_reason":{"nullable":true,"type":"string"},"reported_done_at":{"format":"date-time","nullable":true,"type":"string"},"sort_key":{"nullable":true,"type":"string"},"tenant_id":{"format":"uuid","type":"string"},"title":{"example":"Implement user authentication","type":"string"},"updated_at":{"format":"date-time","type":"string"},"verified_at":{"format":"date-time","nullable":true,"type":"string"},"verified_status":{"enum":["unverified","verified","rejected"],"example":"unverified","type":"string"}},"title":"StoryResponse","type":"object"},"ModelMixEntry":{"description":"A (model_name, phase) correlation matrix entry with token totals, cost, story count, and verification outcomes.","example":{"model_name":"claude-opus-4-5","phase":"implementing","story_count":30,"total_cost_millicents":8550000,"total_input_tokens":6250000,"total_output_tokens":2400000,"verification_rate":0.933,"verified_count":28},"properties":{"model_name":{"example":"claude-opus-4-5","type":"string"},"phase":{"enum":["planning","implementing","reviewing","other"],"type":"string"},"story_count":{"type":"integer"},"total_cost_millicents":{"type":"integer"},"total_input_tokens":{"type":"integer"},"total_output_tokens":{"type":"integer"},"verification_rate":{"type":"number"},"verified_count":{"type":"integer"}},"title":"ModelMixEntry","type":"object"},"BulkStoryResult":{"description":"Result of a single story in a bulk operation","example":{"error":null,"status":"success","story_id":"e5f6a7b8-c9d0-1234-efab-345678901234"},"properties":{"error":{"description":"Error message if status is \"error\"","nullable":true,"type":"string"},"status":{"description":"Whether this story's operation succeeded","enum":["success","error"],"type":"string"},"story_id":{"description":"The story ID","format":"uuid","type":"string"}},"required":["story_id","status"],"title":"BulkStoryResult","type":"object"},"ProjectCreateRequest":{"description":"Request body for creating a project","example":{"name":"My Project","repo_url":"https://github.com/org/repo","slug":"my-project"},"properties":{"description":{"nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"example":"My Project","type":"string"},"repo_url":{"example":"https://github.com/org/repo","nullable":true,"type":"string"},"slug":{"example":"my-project","type":"string"},"tech_stack":{"example":"elixir,phoenix","nullable":true,"type":"string"}},"required":["name","slug"],"title":"ProjectCreateRequest","type":"object"},"ApiKeyResponse":{"description":"API key (list view, no raw key)","example":{"expires_at":null,"id":"b2c3d4e5-f6a7-8901-bcde-f12345678901","inserted_at":"2026-01-15T10:00:00Z","key_prefix":"lc_abc1","last_used_at":"2026-03-25T14:30:00Z","name":"default","revoked_at":null,"role":"user"},"properties":{"expires_at":{"format":"date-time","nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"key_prefix":{"type":"string"},"last_used_at":{"format":"date-time","nullable":true,"type":"string"},"name":{"type":"string"},"revoked_at":{"format":"date-time","nullable":true,"type":"string"},"role":{"type":"string"}},"title":"ApiKeyResponse","type":"object"},"ImportStoryDependency":{"description":"Story-level dependency declaration","example":{"depends_on":"1.1","story":"1.2"},"properties":{"depends_on":{"description":"Depends-on story number","example":"1.1","type":"string"},"story":{"description":"Story number","example":"1.2","type":"string"}},"required":["story","depends_on"],"title":"ImportStoryDependency","type":"object"},"StoryStatusResponse":{"description":"Story state after a status transition","example":{"story":{"agent_status":"contracted","id":"e5f6a7b8-c9d0-1234-efab-345678901234","number":"US-2.1","title":"Implement user authentication","verified_status":"unverified"}},"properties":{"story":{"additionalProperties":true,"description":"Updated story state","type":"object"}},"title":"StoryStatusResponse","type":"object"},"StartUiTestRequest":{"description":"Request body for starting a UI test run","example":{"guide_reference":"docs/user_guides/checkout_flow.md"},"properties":{"guide_reference":{"description":"Path or URL to the user guide being followed","example":"docs/user_guides/checkout_flow.md","type":"string"}},"required":["guide_reference"],"title":"StartUiTestRequest","type":"object"},"PaginationMeta":{"description":"Pagination metadata returned by list endpoints","example":{"page":1,"page_size":20,"total_count":42,"total_pages":3},"properties":{"page":{"example":1,"type":"integer"},"page_size":{"example":20,"type":"integer"},"total_count":{"example":42,"type":"integer"},"total_pages":{"example":3,"type":"integer"}},"title":"PaginationMeta","type":"object"},"ExportStory":{"description":"Story within an export epic","example":{"agent_status":"verified","estimated_hours":4.0,"number":"1.1","title":"Login endpoint","verified_status":"verified"},"properties":{"acceptance_criteria":{"items":{"$ref":"#/components/schemas/AcceptanceCriterion"},"nullable":true,"type":"array"},"agent_status":{"enum":["pending","contracted","assigned","implementing","reported_done"],"type":"string"},"assigned_agent_id":{"format":"uuid","nullable":true,"type":"string"},"assigned_at":{"format":"date-time","nullable":true,"type":"string"},"description":{"nullable":true,"type":"string"},"estimated_hours":{"nullable":true,"type":"number"},"metadata":{"additionalProperties":true,"type":"object"},"number":{"example":"1.1","type":"string"},"rejected_at":{"format":"date-time","nullable":true,"type":"string"},"rejection_reason":{"nullable":true,"type":"string"},"reported_done_at":{"format":"date-time","nullable":true,"type":"string"},"title":{"type":"string"},"verified_at":{"format":"date-time","nullable":true,"type":"string"},"verified_status":{"enum":["unverified","verified","rejected"],"type":"string"}},"title":"ExportStory","type":"object"},"RecallContextRequest":{"description":"Params for POST /recall (merged memory ∪ knowledge recall, #411 Gap 2). Query supplied in the body; scope (tenant_id/subject_id) is derived from the API key, never the body.","properties":{"limit":{"description":"Overall merged page size, clamped to [1, 50] (default 10). Applied per-source first, then to the merged, re-ranked list.","type":"integer"},"project_id":{"description":"Optional project scope (a UUID PARTITION key, NOT an isolation boundary). Present → both sides return the merged global ∪ that-project set; absent/blank → global-only. A malformed value is a 422 invalid_project_id.","format":"uuid","nullable":true,"type":"string"},"query":{"description":"Text to embed / match against on BOTH the memory and knowledge sides.","type":"string"}},"title":"RecallContextRequest","type":"object"},"TokenAnalyticsTrend":{"description":"A single data point in a daily or weekly cost trend series.","example":{"period":"2026-03-25","report_count":12,"story_count":8,"total_cost_millicents":648000,"total_input_tokens":487000,"total_output_tokens":189000},"properties":{"period":{"description":"Period label: ISO date for daily, ISO week (YYYY-Www) for weekly","example":"2026-03-25","type":"string"},"report_count":{"type":"integer"},"story_count":{"description":"Number of distinct stories with reports in this period","type":"integer"},"total_cost_millicents":{"type":"integer"},"total_input_tokens":{"type":"integer"},"total_output_tokens":{"type":"integer"}},"title":"TokenAnalyticsTrend","type":"object"},"OrchestratorStateRequest":{"description":"Save orchestrator state (upsert with optimistic locking)","example":{"state_data":{"phase":"epic_3"},"state_key":"main","version":0},"properties":{"state_data":{"additionalProperties":true,"description":"Arbitrary state payload","type":"object"},"state_key":{"description":"State namespace key (default: 'main')","type":"string"},"version":{"description":"Expected version for optimistic lock","nullable":true,"type":"integer"}},"required":["state_key","state_data"],"title":"OrchestratorStateRequest","type":"object"},"MemoryPromoteResponse":{"description":"Confirmation that a session→long-term promotion was enqueued. The reference is the caller's own tenant-scoped `session_id` (the promotion is unique per (tenant, subject, session)); the internal Oban job id is deliberately NOT exposed — it is a system-wide monotonic counter that would leak a cross-tenant throughput side-channel.","properties":{"data":{"properties":{"session_id":{"description":"The session whose promotion was enqueued (the work reference).","type":"string"},"status":{"enum":["enqueued"],"type":"string"}},"type":"object"}},"title":"MemoryPromoteResponse","type":"object"},"MemoryCreateRequest":{"description":"Params for POST /memory. Scope (tenant_id/subject_id) is derived from the API key — any tenant_id/subject_id in the body is ignored.","properties":{"confidence":{"format":"float","nullable":true,"type":"number"},"content":{"description":"Session turn content (tier=session).","type":"string"},"expires_at":{"description":"Prune deadline (tier=session).","format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"description":"Optional: arbitrary structured metadata to attach to the memory (either tier).","nullable":true,"type":"object"},"project_id":{"description":"Optional project scope (a UUID PARTITION key, NOT an isolation boundary). Absent/blank writes a tenant-wide (global) memory; a malformed value is rejected with a 422 invalid_project_id.","format":"uuid","nullable":true,"type":"string"},"role":{"enum":["user","assistant","system","fact"],"type":"string"},"session_id":{"description":"Session id (tier=session).","type":"string"},"source_session_id":{"nullable":true,"type":"string"},"tags":{"items":{"type":"string"},"nullable":true,"type":"array"},"text":{"description":"Long-term memory content (tier=long_term).","type":"string"},"tier":{"description":"Defaults to long_term.","enum":["long_term","session"],"type":"string"}},"title":"MemoryCreateRequest","type":"object"},"RotateAuditKeyRequest":{"description":"Step 2 of the reauth ceremony. Carries the WebAuthn assertion that is verified against the STORED challenge from step 1 before rotation.","properties":{"webauthn_assertion":{"$ref":"#/components/schemas/WebAuthnAssertion"}},"required":["webauthn_assertion"],"title":"RotateAuditKeyRequest","type":"object"},"RevokeAuthenticatorRequest":{"description":"Carries the WebAuthn assertion verified against the STORED revoke_authenticator challenge before the target authenticator is deleted.","properties":{"webauthn_assertion":{"$ref":"#/components/schemas/WebAuthnAssertion"}},"required":["webauthn_assertion"],"title":"RevokeAuthenticatorRequest","type":"object"},"ImportRequest":{"description":"Import work breakdown into a project","example":{"epics":[{"description":"Auth infrastructure","number":1,"stories":[{"acceptance_criteria":[{"criterion":"POST /login returns JWT on valid credentials"},{"criterion":"Invalid credentials return 401"}],"number":"1.1","title":"Implement login endpoint"},{"acceptance_criteria":[{"criterion":"POST /logout invalidates the session"}],"number":"1.2","title":"Implement logout endpoint"}],"title":"User Authentication"}],"story_dependencies":[{"depends_on":"1.2","story":"1.1"}]},"properties":{"epic_dependencies":{"description":"Optional cross-epic dependencies","items":{"$ref":"#/components/schemas/ImportEpicDependency"},"nullable":true,"type":"array"},"epics":{"description":"Array of epic objects with nested stories","items":{"$ref":"#/components/schemas/ImportEpic"},"type":"array"},"story_dependencies":{"description":"Optional cross-story dependencies","items":{"$ref":"#/components/schemas/ImportStoryDependency"},"nullable":true,"type":"array"}},"required":["epics"],"title":"ImportRequest","type":"object"},"EnrollAuthenticatorRequest":{"description":"Step 2 of the opt-in WebAuthn enrollment ceremony. Carries the WebAuthn attestation produced by navigator.credentials.create(), keyed to the challenge_id from step 1. All binary fields are base64url encoded. `reauth_assertion` is REQUIRED (and verified) when the tenant is already human_anchored — a fresh assertion from an existing authenticator.","properties":{"attestation_object":{"description":"Base64url attestation object","type":"string"},"challenge_id":{"format":"uuid","type":"string"},"client_data_json":{"description":"Base64url raw client data JSON","type":"string"},"credential_id":{"description":"Base64url credential id","type":"string"},"friendly_name":{"description":"Operator-supplied label (1..120 chars, default \"Authenticator\")","type":"string"},"reauth_assertion":{"$ref":"#/components/schemas/WebAuthnAssertion"}},"required":["challenge_id","attestation_object","client_data_json","credential_id"],"title":"EnrollAuthenticatorRequest","type":"object"},"ChannelPostRequest":{"description":"Request body for posting to a repo coordination channel. agent_id and tenant_id are stamped server-side from the verified key and are ignored if present in the body.","example":{"body":"pushed PR #107, CI green","key":"session_goal","project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","refs":[{"type":"pr","value":"107"},{"label":"the failing call","type":"file","value":"lib/fly/auth.ex:42"}],"session_id":"S1"},"properties":{"body":{"description":"Free-text message (<= 16KB)","type":"string"},"host":{"description":"Client-supplied hostname","nullable":true,"type":"string"},"idempotency_key":{"description":"Optional client idempotency token for the KEYLESS write path (<=255 bytes). When supplied without a key, a repeat write with the same (tenant, project, agent, idempotency_key) returns the EXISTING post (200, created:false) instead of appending a duplicate — the same guarantee knowledge_create gives. Scoped per-agent, so one agent's token never collides with another's. Absent, the write is exactly append-only. It applies to the keyless append path ONLY: combining it with a key is REJECTED (422) — the keyed slot already dedups a same-session re-fire, so send one or the other, never both.","nullable":true,"type":"string"},"key":{"description":"Optional working-state slot key; a repeat post from the same session upserts it. A handoff should pass a stable key of the form handoff:<anchor> (e.g. handoff:repo#812) so a same-session retry refreshes the same slot.","nullable":true,"type":"string"},"project_id":{"description":"The channel — a project the caller's tenant owns","format":"uuid","type":"string"},"refs":{"description":"Optional bounded, typed-open LIST of reference items (max 50). Each item is {type, value, label?}: type is a FREE string (<=64 bytes, no allowlist), value <=512 bytes, optional label <=128 bytes. A secret/NUL byte in ANY item field is rejected (422).","items":{"additionalProperties":false,"properties":{"label":{"description":"Optional human label (<=128 bytes)","nullable":true,"type":"string"},"type":{"description":"Free-form ref type (<=64 bytes)","type":"string"},"value":{"description":"Ref value (<=512 bytes)","type":"string"}},"required":["type","value"],"type":"object"},"nullable":true,"type":"array"},"session_id":{"description":"Client-supplied session id (required when key is set)","nullable":true,"type":"string"},"supersedes":{"description":"Optional id of a post this one retires. The target must live in the same tenant+project channel, and the caller must be its author or hold role >= :user. A superseded post is excluded from handoff discovery and marked in the history read.","format":"uuid","nullable":true,"type":"string"},"to_capability":{"description":"Optional ADVISORY SURFACING address: the intended target capability, e.g. \"fly auth\" (<=128 bytes). Client-supplied and SPOOFABLE — a discovery hint only, NEVER authorization, ownership, or a delivery guarantee. Prefer this over to_host when the real target is a capability rather than a machine.","nullable":true,"type":"string"},"to_host":{"description":"Optional ADVISORY SURFACING address: the intended target host (<=255 bytes). Client-supplied and SPOOFABLE — a discovery hint only, NEVER authorization, ownership, or a delivery guarantee. It gates nothing; a post with no addressing is a broadcast visible to everyone on the channel.","nullable":true,"type":"string"}},"required":["project_id","body"],"title":"ChannelPostRequest","type":"object"},"LlmUsageResponse":{"description":"Per-tenant LLM token-usage summary, grouped by operation + model + provider + source_type + day over an optional date range. Record-only — there is no budget enforcement.","properties":{"data":{"items":{"properties":{"day":{"format":"date-time","type":"string"},"event_count":{"type":"integer"},"input_tokens":{"type":"integer"},"model":{"type":"string"},"operation":{"enum":["extraction","classification","merge","embedding"],"type":"string"},"output_tokens":{"type":"integer"},"provider":{"description":"Which provider surface the tokens were spent on. Rows recorded before US-41.3 are attributed by their operation.","enum":["anthropic","openai_compatible","embedding"],"type":"string"},"source_type":{"nullable":true,"type":"string"}},"type":"object"},"type":"array"},"meta":{"$ref":"#/components/schemas/LlmUsageMeta"}},"title":"LlmUsageResponse","type":"object"},"RetrieveResponse":{"description":"A page of allowlisted-column result maps plus pagination meta. Only the entity's declared columns appear in each result.","properties":{"meta":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total_count":{"type":"integer"}},"type":"object"},"results":{"items":{"additionalProperties":true,"type":"object"},"type":"array"}},"title":"RetrieveResponse","type":"object"},"AuditKeyResponse":{"description":"Result of an audit-key rotation or bootstrap.","properties":{"data":{"properties":{"audit_signing_public_key":{"description":"Base64-encoded ed25519 public key","type":"string"},"message":{"type":"string"},"rotated_at":{"format":"date-time","nullable":true,"type":"string"},"tenant_id":{"format":"uuid","type":"string"}},"type":"object"}},"required":["data"],"title":"AuditKeyResponse","type":"object"},"ReauthChallengeResponse":{"description":"Step 1 of the audit-key rotation reauth ceremony (crypto-01). The server-minted, single-use `challenge_id` must be echoed back in the assertion step. All binary fields are base64url encoded.","properties":{"data":{"properties":{"allowed_credentials":{"description":"Base64url credential ids the client may assert with","items":{"type":"string"},"type":"array"},"challenge":{"description":"Base64url-encoded challenge bytes to feed into navigator.credentials.get()","type":"string"},"challenge_id":{"description":"Opaque single-use handle for the stored challenge","format":"uuid","type":"string"},"expires_at":{"format":"date-time","type":"string"},"rp_id":{"description":"Relying party id","type":"string"}},"required":["challenge_id","challenge","allowed_credentials"],"type":"object"}},"required":["data"],"title":"ReauthChallengeResponse","type":"object"},"SkillResponse":{"description":"Skill resource","example":{"current_version":3,"description":"Code review skill for orchestrator verification","id":"b8c9d0e1-f2a3-4567-bcde-678901234567","inserted_at":"2026-01-15T10:00:00Z","metadata":{},"name":"loopctl:review","project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","status":"active","updated_at":"2026-03-20T09:15:00Z"},"properties":{"current_version":{"type":"integer"},"description":{"nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"status":{"enum":["active","archived"],"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"SkillResponse","type":"object"},"EpicResponse":{"description":"Epic resource","example":{"description":"Core infrastructure and setup","id":"d4e5f6a7-b8c9-0123-defa-234567890123","inserted_at":"2026-01-15T10:00:00Z","metadata":{},"number":1,"phase":"p0","position":1,"project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","tenant_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","title":"Foundation","updated_at":"2026-01-15T10:00:00Z"},"properties":{"description":{"nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"number":{"type":"integer"},"phase":{"nullable":true,"type":"string"},"position":{"type":"integer"},"project_id":{"format":"uuid","type":"string"},"tenant_id":{"format":"uuid","type":"string"},"title":{"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"EpicResponse","type":"object"},"MemoryRecallResponse":{"description":"Recall results with pinned meta. `score` is null on the text-match fallback path; `meta.fallback`/`meta.reason` flag degradation and `meta.underfilled` a short page.","properties":{"data":{"items":{"properties":{"memory":{"$ref":"#/components/schemas/Memory"},"score":{"format":"float","nullable":true,"type":"number"}},"type":"object"},"type":"array"},"meta":{"properties":{"fallback":{"type":"boolean"},"reason":{"nullable":true,"type":"string"},"total_count":{"type":"integer"},"underfilled":{"type":"boolean"}},"type":"object"}},"title":"MemoryRecallResponse","type":"object"},"EntityDefinition":{"description":"A tenant-authored entity definition: a named, typed view over a loopctl-internal backing source (projects/stories/epics). Its declared fields ARE the executor's field allowlist.","properties":{"backing_source":{"enum":["projects","stories","epics"],"type":"string"},"fields":{"items":{"$ref":"#/components/schemas/EntityDefinitionField"},"type":"array"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"name":{"type":"string"},"tenant_id":{"format":"uuid","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"EntityDefinition","type":"object"},"TokenUsageReport":{"description":"A token usage report for an agent story. Tracks input/output tokens, model name, and cost in millicents (1/1000 of a cent). Corrections use negative values.","example":{"agent_id":"d4e5f6a7-b8c9-0123-defa-234567890123","corrects_report_id":null,"cost_dollars":"1.88","cost_millicents":187500,"deleted_at":null,"id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","input_tokens":125000,"inserted_at":"2026-03-25T14:30:00Z","metadata":{},"model_name":"claude-opus-4-5","output_tokens":48000,"phase":"implementing","project_id":"e5f6a7b8-c9d0-1234-efab-345678901234","session_id":"sess_abc123","skill_version_id":null,"story_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","tenant_id":"b2c3d4e5-f6a7-8901-bcde-f12345678901","total_tokens":173000,"updated_at":"2026-03-25T14:30:00Z"},"properties":{"agent_id":{"format":"uuid","nullable":true,"type":"string"},"corrects_report_id":{"format":"uuid","nullable":true,"type":"string"},"cost_dollars":{"description":"Cost formatted as dollars (e.g. \"1.23\")","example":"1.23","type":"string"},"cost_millicents":{"description":"Cost in millicents (1/1000 of a cent)","type":"integer"},"deleted_at":{"format":"date-time","nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"input_tokens":{"description":"Number of input tokens consumed","type":"integer"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"model_name":{"description":"LLM model name","example":"claude-opus-4-5","type":"string"},"output_tokens":{"description":"Number of output tokens consumed","type":"integer"},"phase":{"description":"Work phase when tokens were consumed","enum":["planning","implementing","reviewing","other"],"type":"string"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"session_id":{"nullable":true,"type":"string"},"skill_version_id":{"format":"uuid","nullable":true,"type":"string"},"story_id":{"format":"uuid","type":"string"},"tenant_id":{"format":"uuid","type":"string"},"total_tokens":{"description":"DB-generated column: input_tokens + output_tokens","type":"integer"},"updated_at":{"format":"date-time","type":"string"}},"title":"TokenUsageReport","type":"object"},"MemoryPromoteRequest":{"description":"Params for POST /memory/promote. Scope (tenant_id/subject_id) is derived from the API key — only `session_id` is read from the body.","properties":{"session_id":{"description":"The session to promote into long-term memory.","type":"string"}},"required":["session_id"],"title":"MemoryPromoteRequest","type":"object"},"LlmUsageMeta":{"description":"Offset/limit pagination metadata for the LLM usage summary, plus the EFFECTIVE date window actually applied. Advance `offset` by `limit` to enumerate `total_count` grouped rows. When `from` is omitted it defaults to a 90-day lookback (echoed here so callers can detect the truncation).","example":{"from":"2026-04-04T00:00:00Z","limit":50,"offset":0,"to":null,"total_count":3},"properties":{"from":{"description":"Effective lower bound applied (defaults to now − 90 days when omitted)","format":"date-time","type":"string"},"limit":{"description":"Effective page size (rows returned)","type":"integer"},"offset":{"description":"Rows skipped","type":"integer"},"to":{"description":"Effective upper bound applied (null = open-ended / now)","format":"date-time","nullable":true,"type":"string"},"total_count":{"description":"Total grouped rows across all pages","type":"integer"}},"title":"LlmUsageMeta","type":"object"},"ExportResponse":{"description":"Complete project export with round-trip fidelity","example":{"epic_dependencies":[],"epics":[{"number":1,"stories":[{"agent_status":"pending","number":"1.1","title":"Setup","verified_status":"unverified"}],"title":"Foundation"}],"export_metadata":{"exported_at":"2026-03-25T14:30:00Z","loopctl_version":"0.1.0","project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","tenant_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"},"project":{"name":"My Project","slug":"my-project","status":"active"},"story_dependencies":[]},"properties":{"epic_dependencies":{"description":"Epic-level dependencies using epic numbers","items":{"$ref":"#/components/schemas/ImportEpicDependency"},"type":"array"},"epics":{"items":{"$ref":"#/components/schemas/ExportEpic"},"type":"array"},"export_metadata":{"$ref":"#/components/schemas/ExportMetadata"},"project":{"$ref":"#/components/schemas/ExportProject"},"story_dependencies":{"description":"Story-level dependencies using story numbers","items":{"$ref":"#/components/schemas/ImportStoryDependency"},"type":"array"}},"title":"ExportResponse","type":"object"},"ImportEpic":{"description":"Epic within an import payload","example":{"number":1,"stories":[{"number":"1.1","title":"Login endpoint"}],"title":"User Authentication"},"properties":{"description":{"nullable":true,"type":"string"},"number":{"description":"Epic number","example":1,"type":"integer"},"phase":{"nullable":true,"type":"string"},"position":{"nullable":true,"type":"integer"},"stories":{"description":"Stories nested under this epic","items":{"$ref":"#/components/schemas/ImportStory"},"type":"array"},"title":{"example":"User Authentication","type":"string"}},"required":["number","title"],"title":"ImportEpic","type":"object"},"MemoryListResponse":{"description":"A paginated list of memories.","properties":{"data":{"items":{"$ref":"#/components/schemas/Memory"},"type":"array"},"meta":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total_count":{"type":"integer"}},"type":"object"}},"title":"MemoryListResponse","type":"object"},"WebhookCreateRequest":{"description":"Create a webhook subscription","example":{"events":["story.verified","story.rejected","token.budget_warning"],"project_id":null,"url":"https://example.com/webhook"},"properties":{"events":{"description":"Event types to subscribe to","items":{"enum":["story.status_changed","story.verified","story.rejected","story.auto_reset","story.force_unclaimed","epic.completed","artifact.reported","agent.registered","project.imported","token.budget_warning","token.budget_exceeded","token.anomaly_detected","webhook.test"],"type":"string"},"type":"array"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"url":{"example":"https://example.com/webhook","format":"uri","type":"string"}},"required":["url","events"],"title":"WebhookCreateRequest","type":"object"},"CompleteUiTestRequest":{"description":"Request body for completing a UI test run","example":{"status":"failed","summary":"Tested 12 flows. Found 2 critical issues in checkout. Cart and auth flows passed."},"properties":{"status":{"description":"Final status of the test run","enum":["passed","failed"],"type":"string"},"summary":{"description":"Human-readable summary of the test run","example":"Tested 12 flows. Found 2 critical issues in checkout. Cart and auth flows passed.","type":"string"}},"required":["status","summary"],"title":"CompleteUiTestRequest","type":"object"},"BulkRejectRequest":{"description":"Bulk reject stories","example":{"stories":[{"reason":"Missing LiveView tests","story_id":"e5f6a7b8-c9d0-1234-efab-345678901234"}]},"properties":{"stories":{"items":{"properties":{"reason":{"type":"string"},"story_id":{"format":"uuid","type":"string"}},"type":"object"},"type":"array"}},"required":["stories"],"title":"BulkRejectRequest","type":"object"},"CostAnomaly":{"description":"A detected cost anomaly for a story. Generated by the daily rollup worker. Types: high_cost (>3x epic avg), suspiciously_low (<0.1x), budget_exceeded (over configured budget).","example":{"anomaly_type":"high_cost","archived":false,"deviation_factor":3.6,"id":"d4e5f6a7-b8c9-0123-defa-234567890123","inserted_at":"2026-03-26T01:00:00Z","metadata":{},"reference_avg_millicents":125000,"resolved":false,"story_cost_millicents":450000,"story_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","tenant_id":"b2c3d4e5-f6a7-8901-bcde-f12345678901","updated_at":"2026-03-26T01:00:00Z"},"properties":{"anomaly_type":{"description":"Type of cost anomaly detected","enum":["high_cost","suspiciously_low","budget_exceeded"],"type":"string"},"archived":{"description":"Whether the anomaly is archived (excluded from default list)","type":"boolean"},"deviation_factor":{"description":"How many times the story cost deviates from the reference average","type":"number"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"reference_avg_millicents":{"description":"The epic average cost used for comparison","type":"integer"},"resolved":{"description":"Whether the anomaly has been acknowledged and resolved","type":"boolean"},"story_cost_millicents":{"description":"The story's actual total cost in millicents","type":"integer"},"story_id":{"format":"uuid","type":"string"},"tenant_id":{"format":"uuid","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"CostAnomaly","type":"object"},"AuthenticatorChallengeResponse":{"description":"Step 1 of the opt-in WebAuthn enrollment ceremony (US-26.7.2). Returns the publicKey creation options a browser WebAuthn client needs to call navigator.credentials.create(). If the tenant is already human_anchored, also includes a fresh-assertion (reauth) challenge for an EXISTING authenticator (`reauth_required: true`) — a subsequent enrollment must prove possession of an already-enrolled device before adding a backup.","properties":{"data":{"properties":{"challenge":{"description":"Base64url-encoded challenge bytes to feed into navigator.credentials.create()","type":"string"},"challenge_id":{"description":"Opaque single-use handle for the stored registration challenge","format":"uuid","type":"string"},"expires_at":{"format":"date-time","type":"string"},"pub_key_cred_params":{"description":"Accepted public key algorithms (ES256, RS256)","items":{"type":"object"},"type":"array"},"reauth_challenge":{"description":"Present only when reauth_required is true","nullable":true,"properties":{"allowed_credentials":{"items":{"type":"string"},"type":"array"},"challenge":{"type":"string"},"challenge_id":{"format":"uuid","type":"string"},"expires_at":{"format":"date-time","type":"string"},"rp_id":{"type":"string"}},"type":"object"},"reauth_required":{"description":"true when this is a subsequent (backup) enrollment","type":"boolean"},"rp":{"properties":{"id":{"description":"Relying party id","type":"string"},"name":{"description":"Relying party display name","type":"string"}},"type":"object"},"user":{"properties":{"display_name":{"type":"string"},"id":{"description":"Base64url opaque per-tenant WebAuthn user handle","type":"string"},"name":{"type":"string"}},"type":"object"}},"required":["challenge_id","challenge","expires_at","rp","user","pub_key_cred_params"],"type":"object"}},"required":["data"],"title":"AuthenticatorChallengeResponse","type":"object"},"ExportEpic":{"description":"Epic within an export payload","example":{"description":"Auth infrastructure","metadata":{},"number":1,"phase":"p0","position":1,"stories":[{"agent_status":"reported_done","number":"1.1","title":"Implement login endpoint","verified_status":"verified"}],"title":"User Authentication"},"properties":{"description":{"nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"number":{"type":"integer"},"phase":{"nullable":true,"type":"string"},"position":{"type":"integer"},"stories":{"items":{"$ref":"#/components/schemas/ExportStory"},"type":"array"},"title":{"type":"string"}},"title":"ExportEpic","type":"object"},"RejectRequest":{"description":"Orchestrator rejects a story with reason","example":{"findings":{"missing_tests":["empty input handling","error boundary"]},"reason":"Missing LiveView tests","review_type":"enhanced_review"},"properties":{"findings":{"additionalProperties":true,"description":"Structured findings from the review","type":"object"},"reason":{"description":"Rejection reason (required, cannot be blank)","example":"Missing LiveView tests","type":"string"},"review_type":{"description":"Type of review performed (e.g. enhanced_review, quick_check)","example":"enhanced_review","type":"string"}},"required":["reason"],"title":"RejectRequest","type":"object"},"RevokeAuthenticatorResponse":{"description":"Result of a successful authenticator revocation.","properties":{"data":{"properties":{"authenticator_id":{"format":"uuid","type":"string"},"revoked":{"type":"boolean"},"tenant_id":{"format":"uuid","type":"string"}},"type":"object"}},"required":["data"],"title":"RevokeAuthenticatorResponse","type":"object"},"AgentRegisterRequest":{"description":"Self-registration request for an agent","example":{"agent_type":"implementer","name":"worker-1"},"properties":{"agent_type":{"enum":["orchestrator","implementer"],"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"}},"required":["name","agent_type"],"title":"AgentRegisterRequest","type":"object"},"IngestionBacklogError":{"description":"Batch-ingest backlog backpressure (HTTP 429). Distinct from the generic request RateLimitError: this is triggered BEFORE any item is enqueued when the calling tenant's in-flight `:ingestion` backlog (non-terminal Oban jobs) is at/over the `OBAN_INGEST_BACKLOG_MAX` threshold. The rejection is all-or-nothing — NO jobs from the request are enqueued (no partial pile-up). The check is tenant-scoped: only the caller's own backlog counts. Unlike the Hammer request-rate limiter, this response carries a machine-readable `error.code` of `ingestion_backlog_exceeded`, so dashboards/clients can tell backlog backpressure apart from the request-rate 429. It DOES set `Retry-After` (seconds) — back off and retry once the backlog drains.","example":{"error":{"code":"ingestion_backlog_exceeded","message":"This tenant already has too many in-flight ingestion jobs queued. No items from this batch were enqueued. Retry after 5 seconds once the backlog drains.","retry_after_seconds":5,"status":429}},"properties":{"error":{"properties":{"code":{"enum":["ingestion_backlog_exceeded"],"example":"ingestion_backlog_exceeded","type":"string"},"message":{"example":"This tenant already has too many in-flight ingestion jobs queued. No items from this batch were enqueued. Retry after 5 seconds once the backlog drains.","type":"string"},"retry_after_seconds":{"example":5,"type":"integer"},"status":{"example":429,"type":"integer"}},"required":["status","code","message"],"type":"object"}},"required":["error"],"title":"IngestionBacklogError","type":"object"},"PromotionBudgetError":{"description":"Promotion budget exceeded (HTTP 429). Distinct from the generic request RateLimitError: this is the tenant's PER-HOUR memory-promotion (compile) budget — a semantic limit that caps how many session→long-term promotions may run per hour so a spamming agent cannot exhaust the tenant's BYO LLM key. The session was NOT enqueued and no LLM call was made. Unlike the request limiter, this response carries a machine-readable `error.code` of `promotion_budget_exceeded` and does NOT set `Retry-After` or `X-RateLimit-*` headers (the budget refills on a rolling hourly window, not a fixed per-request window) — clients should back off and retry later rather than read a reset header.","example":{"error":{"code":"promotion_budget_exceeded","message":"The tenant's per-hour memory-promotion budget has been reached. The session was not enqueued and no LLM call was made; retry later.","status":429}},"properties":{"error":{"properties":{"code":{"enum":["promotion_budget_exceeded"],"example":"promotion_budget_exceeded","type":"string"},"message":{"example":"The tenant's per-hour memory-promotion budget has been reached. The session was not enqueued and no LLM call was made; retry later.","type":"string"},"status":{"example":429,"type":"integer"}},"required":["status","code","message"],"type":"object"}},"required":["error"],"title":"PromotionBudgetError","type":"object"},"TokenAnalyticsProject":{"description":"Comprehensive cost overview for a single project including phase and model breakdown.","example":{"avg_cost_per_story_millicents":277500,"budget_millicents":20000000,"budget_utilization_pct":83.25,"epic_count":15,"model_breakdown":{"claude-haiku-3-5":550000,"claude-opus-4-5":12300000,"claude-sonnet-5":3800000},"phase_breakdown":{"implementing":9800000,"other":750000,"planning":1900000,"reviewing":4200000},"project_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","project_name":"loopctl","story_count":60,"total_cost_millicents":16650000,"total_input_tokens":12500000,"total_output_tokens":4800000},"properties":{"avg_cost_per_story_millicents":{"type":"integer"},"budget_millicents":{"nullable":true,"type":"integer"},"budget_utilization_pct":{"nullable":true,"type":"number"},"epic_count":{"type":"integer"},"model_breakdown":{"additionalProperties":true,"description":"Cost breakdown by model name","type":"object"},"phase_breakdown":{"additionalProperties":true,"description":"Cost breakdown by phase (planning, implementing, reviewing, other)","type":"object"},"project_id":{"format":"uuid","type":"string"},"project_name":{"type":"string"},"story_count":{"type":"integer"},"total_cost_millicents":{"type":"integer"},"total_input_tokens":{"type":"integer"},"total_output_tokens":{"type":"integer"}},"title":"TokenAnalyticsProject","type":"object"},"MemoryGraduateRequest":{"description":"Params for POST /memory/graduate (#411 Gap 3). Scope (tenant_id/subject_id) is derived from the API key — only `memory_id` and the optional `re_scope` are read from the body.","properties":{"memory_id":{"description":"UUID of the caller's OWN long-term memory to graduate into a knowledge article.","format":"uuid","type":"string"},"re_scope":{"description":"Article scope. `inherit` (default) keeps the memory's own `project_id` (project memory → project article, global memory → global article). `global` promotes a PROJECT memory to a tenant-wide (project_id: null) article — only valid on the memory's FIRST graduation.","enum":["inherit","global"],"type":"string"}},"required":["memory_id"],"title":"MemoryGraduateRequest","type":"object"},"TenantUpdateRequest":{"description":"Request body for `PATCH /api/v1/tenants/me` (and the superadmin `PATCH /api/v1/admin/tenants/:id`). NOTE: `slug` is intentionally absent — it is immutable after creation because it keys the tenant's audit-key secret name (security: rls-02, advisory GHSA-v62j-7vgr-rfqp). Sending `slug` has no effect.","example":{"email":"admin@example.com","name":"My Org","settings":{},"token_data_retention_days":90},"properties":{"default_story_budget_millicents":{"description":"Tenant-wide default story budget (millicents); null to unset","nullable":true,"type":"integer"},"email":{"description":"Contact email","format":"email","type":"string"},"name":{"description":"Tenant display name","type":"string"},"settings":{"additionalProperties":true,"type":"object"},"token_data_retention_days":{"description":"Token-usage retention in days (>= 30); null disables archival","nullable":true,"type":"integer"}},"title":"TenantUpdateRequest","type":"object"},"RateLimitError":{"description":"Rate limit exceeded. Default limits: 300 requests/minute per API key and 3x that per tenant (superadmin keys are exempt; per-tenant overrides via the `rate_limit_requests_per_minute` setting). Back off using the response headers: `Retry-After` (seconds until the window resets, always >= 1), plus `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (Unix epoch seconds), which are set on every response.","example":{"error":{"message":"Rate limit exceeded","status":429}},"properties":{"error":{"properties":{"message":{"example":"Rate limit exceeded","type":"string"},"status":{"example":429,"type":"integer"}},"required":["status","message"],"type":"object"}},"title":"RateLimitError","type":"object"},"ExportProject":{"description":"Project metadata in an export","example":{"description":"An example project","metadata":{},"name":"My Project","repo_url":"https://github.com/org/repo","slug":"my-project","status":"active","tech_stack":"elixir,phoenix"},"properties":{"description":{"nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"},"repo_url":{"nullable":true,"type":"string"},"slug":{"type":"string"},"status":{"enum":["active","archived"],"type":"string"},"tech_stack":{"nullable":true,"type":"string"}},"title":"ExportProject","type":"object"},"UiTestFindingRequest":{"description":"A structured finding recorded during a UI test run","example":{"console_errors":"Uncaught TypeError: Cannot read properties of undefined","description":"Page crashes with 500 error when submitting empty form","screenshot_path":"screenshots/checkout_crash.png","severity":"critical","step":"3. Submit checkout form","type":"crash"},"properties":{"console_errors":{"description":"Optional console error output","nullable":true,"type":"string"},"description":{"description":"Human-readable description of the finding","type":"string"},"screenshot_path":{"description":"Optional path to a screenshot","nullable":true,"type":"string"},"severity":{"description":"Finding severity level","enum":["critical","high","medium","low"],"type":"string"},"step":{"description":"The UI step where the finding occurred","type":"string"},"type":{"description":"Finding type (crash, wrong_behavior, ui_defect, etc.)","type":"string"}},"title":"UiTestFindingRequest","type":"object"},"ImportStory":{"description":"Story within an import epic","example":{"acceptance_criteria":[{"criterion":"Returns JWT"}],"estimated_hours":4.0,"initial_verified_status":"verified","number":"1.1","title":"Implement login endpoint"},"properties":{"acceptance_criteria":{"description":"List of acceptance criteria","items":{"$ref":"#/components/schemas/AcceptanceCriterion"},"nullable":true,"type":"array"},"depends_on_stories":{"description":"Story numbers this story depends on","items":{"type":"string"},"nullable":true,"type":"array"},"description":{"nullable":true,"type":"string"},"estimated_hours":{"example":4.0,"nullable":true,"type":"number"},"initial_agent_status":{"description":"Set initial agent status at import time. Use 'reported_done' for pre-existing work that has been completed.","enum":["pending","reported_done"],"nullable":true,"type":"string"},"initial_verified_status":{"description":"Set initial verified status at import time. Use 'verified' for pre-existing work that has already been verified. When set to 'verified', agent_status is also set to 'reported_done'.","enum":["unverified","verified"],"nullable":true,"type":"string"},"number":{"description":"Story number (e.g. \"1.1\")","example":"1.1","type":"string"},"title":{"example":"Implement login endpoint","type":"string"}},"required":["number","title"],"title":"ImportStory","type":"object"},"ReviewRecordResponse":{"description":"Review record proving an independent review was conducted","example":{"completed_at":"2026-03-30T01:44:41Z","findings_count":5,"fixes_count":5,"id":"f1a2b3c4-d5e6-7890-abcd-ef1234567890","inserted_at":"2026-03-30T01:44:41Z","review_type":"enhanced","reviewer_agent_id":"c3d4e5f6-a7b8-9012-cdef-123456789012","story_id":"b2c3d4e5-f6a7-8901-bcde-f12345678901","summary":"Enhanced review completed. 5 findings, all fixed.","tenant_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","updated_at":"2026-03-30T01:44:41Z"},"properties":{"completed_at":{"format":"date-time","type":"string"},"findings_count":{"type":"integer"},"fixes_count":{"type":"integer"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"review_type":{"type":"string"},"reviewer_agent_id":{"format":"uuid","nullable":true,"type":"string"},"story_id":{"format":"uuid","type":"string"},"summary":{"nullable":true,"type":"string"},"tenant_id":{"format":"uuid","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"ReviewRecordResponse","type":"object"},"ArtifactReportRequest":{"description":"Submit an artifact report for a story","example":{"artifact_type":"commit_diff","details":{"files_changed":5},"exists":true,"path":"abc123..def456"},"properties":{"artifact_type":{"description":"Type of artifact (e.g. file, test, migration)","type":"string"},"details":{"additionalProperties":true,"description":"Additional details","type":"object"},"exists":{"description":"Whether the artifact exists","type":"boolean"},"path":{"description":"File path or identifier","type":"string"}},"required":["artifact_type","path"],"title":"ArtifactReportRequest","type":"object"},"AcceptanceCriterion":{"description":"A single acceptance criterion. Accepts both `{\"criterion\": \"...\"}` and `{\"id\": \"AC-1\", \"description\": \"...\"}` formats. When `description` is present it is mapped to `criterion` automatically.","example":{"description":"POST /login returns JWT on valid credentials","id":"AC-1"},"properties":{"criterion":{"description":"Acceptance criterion text (canonical key)","type":"string"},"description":{"description":"Acceptance criterion text (alias for criterion, normalized on import)","type":"string"},"id":{"description":"Optional identifier (e.g. \"AC-1\")","example":"AC-1","type":"string"}},"title":"AcceptanceCriterion","type":"object"},"VerifyRequest":{"description":"Orchestrator verifies a reported_done story. Requires review_type and summary as proof that an independent review was conducted.","example":{"findings":{},"result":"pass","review_type":"enhanced","summary":"Enhanced review: 2 rounds, 6 agents, 5 bugs fixed, 0 deferrals"},"properties":{"findings":{"additionalProperties":true,"description":"Structured findings from the review","type":"object"},"result":{"default":"pass","description":"Verification result: pass (full) or partial","enum":["pass","partial"],"type":"string"},"review_type":{"description":"Required. Type of independent review performed. Examples: enhanced, team, adversarial, single_agent","example":"enhanced","type":"string"},"summary":{"description":"Required. Human-readable summary of the review findings. Must describe what was reviewed and what was found.","example":"Enhanced review: 2 rounds, 6 agents, 5 bugs fixed, 0 deferrals","type":"string"}},"required":["review_type","summary"],"title":"VerifyRequest","type":"object"},"ContractRequest":{"description":"Agent acknowledges story acceptance criteria","example":{"ac_count":8,"story_title":"Implement user authentication"},"properties":{"ac_count":{"description":"Must match the number of acceptance criteria","example":8,"type":"integer"},"story_title":{"description":"Must match the story title exactly","example":"Implement user authentication","type":"string"}},"required":["story_title","ac_count"],"title":"ContractRequest","type":"object"},"ErrorResponse":{"description":"Standard error envelope","example":{"error":{"message":"Not found","status":404}},"properties":{"error":{"properties":{"details":{"additionalProperties":true,"description":"Field-level error details (optional)","type":"object"},"message":{"description":"Human-readable message","example":"Validation failed","type":"string"},"status":{"description":"HTTP status code","example":422,"type":"integer"}},"required":["status","message"],"type":"object"}},"required":["error"],"title":"ErrorResponse","type":"object"},"MemoryGraduateResponse":{"description":"Result of graduating a memory into a durable knowledge article. `verdict` is the novelty-gate outcome; `created` is true only when a new article was materialized (`created`/`gated_to_draft`), false for a dedup (`duplicate`/`deduplicated`). The `article` is a body-less summary — fetch the full body via GET /articles/:id.","properties":{"data":{"properties":{"article":{"description":"Body-less summary of the resulting (created or canonical) article.","type":"object"},"created":{"description":"Whether a NEW article (published or review draft) was materialized.","type":"boolean"},"verdict":{"description":"The novelty-gate verdict for the graduated content.","enum":["created","gated_to_draft","duplicate","deduplicated"],"type":"string"}},"type":"object"}},"title":"MemoryGraduateResponse","type":"object"},"VerificationResultResponse":{"description":"Verification result record","example":{"id":"d0e1f2a3-b4c5-6789-defa-890123456789","inserted_at":"2026-03-25T15:00:00Z","reason":"All acceptance criteria met","result":"pass","story_id":"e5f6a7b8-c9d0-1234-efab-345678901234"},"properties":{"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"reason":{"nullable":true,"type":"string"},"result":{"enum":["pass","fail"],"type":"string"},"story_id":{"format":"uuid","type":"string"}},"title":"VerificationResultResponse","type":"object"},"TokenAnalyticsEpic":{"description":"Per-epic cost breakdown including budget utilization and model breakdown.","example":{"avg_cost_per_story_millicents":225000,"budget_millicents":3000000,"budget_utilization_pct":82.5,"epic_id":"e5f6a7b8-c9d0-1234-efab-345678901234","epic_number":21,"epic_title":"Token Efficiency","story_count":11,"total_cost_millicents":2475000,"total_input_tokens":1875000,"total_output_tokens":720000},"properties":{"avg_cost_per_story_millicents":{"type":"integer"},"budget_millicents":{"description":"Configured budget for this epic (nil if no budget)","nullable":true,"type":"integer"},"budget_utilization_pct":{"description":"Percentage of budget consumed (nil if no budget)","nullable":true,"type":"number"},"epic_id":{"format":"uuid","type":"string"},"epic_number":{"type":"integer"},"epic_title":{"type":"string"},"story_count":{"type":"integer"},"total_cost_millicents":{"type":"integer"},"total_input_tokens":{"type":"integer"},"total_output_tokens":{"type":"integer"}},"title":"TokenAnalyticsEpic","type":"object"},"BulkClaimRequest":{"description":"Bulk claim stories","example":{"story_ids":["e5f6a7b8-c9d0-1234-efab-345678901234","f6a7b8c9-d0e1-2345-fabc-456789012345"]},"properties":{"story_ids":{"description":"Story IDs to claim (max 50)","items":{"format":"uuid","type":"string"},"type":"array"}},"required":["story_ids"],"title":"BulkClaimRequest","type":"object"},"AuthenticatorEnrollResponse":{"description":"Result of a successful authenticator enrollment.","properties":{"data":{"properties":{"authenticator":{"properties":{"attestation_format":{"type":"string"},"friendly_name":{"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"}},"type":"object"},"tenant_id":{"format":"uuid","type":"string"},"trust_tier":{"enum":["agent_rooted","human_anchored"],"type":"string"},"upgraded":{"description":"true iff THIS call flipped the tenant to human_anchored","type":"boolean"}},"type":"object"}},"required":["data"],"title":"AuthenticatorEnrollResponse","type":"object"},"BulkResultResponse":{"description":"Per-story results from a bulk operation","example":{"results":[{"error":null,"status":"success","story_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890"},{"error":"Story is not in reported_done status","status":"error","story_id":"b2c3d4e5-f6a7-8901-bcde-f12345678901"}]},"properties":{"results":{"description":"One result per story in the request","items":{"$ref":"#/components/schemas/BulkStoryResult"},"type":"array"}},"required":["results"],"title":"BulkResultResponse","type":"object"},"ApiKeyCreateRequest":{"description":"Request body for creating an API key","example":{"expires_at":null,"name":"my-key","role":"agent"},"properties":{"agent_id":{"description":"Optional linked agent","format":"uuid","type":"string"},"expires_at":{"description":"Optional expiration","format":"date-time","type":"string"},"name":{"type":"string"},"role":{"enum":["user","orchestrator","agent"],"type":"string"}},"required":["name","role"],"title":"ApiKeyCreateRequest","type":"object"},"LlmConfigUpdateRequest":{"description":"Request body for `PATCH /api/v1/tenants/me/llm-config`. Sets the tenant's OWN Anthropic API key and OpenAI embedding key (both encrypted at rest, never returned) and the granular per-operation model choices. Any subset of fields may be sent; omitting a key leaves the existing key untouched. Model ids are free-form (any model the key permits) — not an allow-list.","example":{"api_key":"sk-ant-...","classification_model":"claude-sonnet-4-5-20250929","embedding_api_key":"sk-...","embedding_model":"text-embedding-3-small","extraction_model":"claude-haiku-4-5-20251001","merge_model":"claude-haiku-4-5-20251001"},"properties":{"acknowledge_key_transmission":{"description":"Required when changing `chat_base_url` WITHOUT supplying a matching `chat_api_key`: explicitly acknowledges that the already-stored key will be transmitted to the new host. Not persisted.","type":"boolean","writeOnly":true},"api_key":{"description":"Anthropic API key (write-only; stored encrypted, never returned)","type":"string","writeOnly":true},"chat_api_key":{"description":"Credential for `chat_base_url` (write-only; stored encrypted, never returned). SEPARATE from `api_key` — the Anthropic key is never sent to a tenant-supplied host.","type":"string","writeOnly":true},"chat_base_url":{"description":"API base of an OpenAI-compatible server (the client appends `/chat/completions`). Required when `chat_provider` is `openai_compatible`. PROBED with a trivial completion before it is saved; a probe failure is a 422 and nothing is persisted.","nullable":true,"type":"string"},"chat_provider":{"description":"Which provider serves the CHAT surface (extraction / classification / merge / content extraction / memory promotion). Null or `anthropic` keeps the hardcoded Anthropic endpoint and identical behaviour.","enum":["anthropic","openai_compatible"],"nullable":true,"type":"string"},"classification_model":{"description":"Model id for category classification (null → server default)","nullable":true,"type":"string"},"embedding_api_key":{"description":"OpenAI-compatible embedding API key (write-only; stored encrypted, never returned). Mandatory BYO — without it the tenant's articles are not vector-searchable.","type":"string","writeOnly":true},"embedding_model":{"description":"Embedding model id (null → server default `text-embedding-3-small`)","nullable":true,"type":"string"},"extraction_model":{"description":"Model id for knowledge extraction (null → server default)","nullable":true,"type":"string"},"merge_model":{"description":"Model id for article merge synthesis (null → server default)","nullable":true,"type":"string"}},"title":"LlmConfigUpdateRequest","type":"object"},"EntityDefinitionField":{"description":"A single declared field on an entity definition. `name` must be a column in the SERVER per-source allowlist; `type` is one of the allowed field types. `filterable`/`searchable` gate which tools the field generates.","properties":{"filterable":{"description":"Generate a filter tool. Default false.","type":"boolean"},"name":{"description":"Allowlisted column name (snake_case).","type":"string"},"searchable":{"description":"Contribute to the entity's full-text search tool. Default false.","type":"boolean"},"type":{"enum":["string","integer","boolean","float","datetime"],"type":"string"}},"required":["name","type"],"title":"EntityDefinitionField","type":"object"},"MemoryDeleteResponse":{"description":"Confirmation that a memory was forgotten.","properties":{"data":{"properties":{"deleted":{"type":"boolean"},"id":{"format":"uuid","type":"string"}},"type":"object"}},"title":"MemoryDeleteResponse","type":"object"},"LlmConfigResponse":{"description":"The tenant's LLM configuration. NEVER includes any API key itself — only whether each key is set (`has_api_key` / `has_embedding_key`) and masked last-4 hints (`api_key_hint` / `embedding_api_key_hint`).","example":{"api_key_hint":"...aB3d","chat_api_key_hint":null,"chat_base_url":null,"chat_provider":"anthropic","classification_model":"claude-sonnet-4-5-20250929","embedding_api_key_hint":"...Xy9z","embedding_model":"text-embedding-3-small","extraction_model":"claude-haiku-4-5-20251001","has_api_key":true,"has_chat_key":false,"has_embedding_key":true,"merge_model":null},"properties":{"api_key_hint":{"description":"Masked last-4 hint (e.g. \"...aB3d\"); never the full key","nullable":true,"type":"string"},"chat_api_key_hint":{"description":"Masked last-4 hint for the chat key; never the full key","nullable":true,"type":"string"},"chat_base_url":{"description":"The configured OpenAI-compatible API base, echoed back. NOT a secret — it is the tenant's own declared host and naming it is the point.","nullable":true,"type":"string"},"chat_provider":{"description":"`anthropic` (default) or `openai_compatible`","type":"string"},"classification_model":{"nullable":true,"type":"string"},"embedding_api_key_hint":{"description":"Masked last-4 hint for the embedding key; never the full key","nullable":true,"type":"string"},"embedding_model":{"nullable":true,"type":"string"},"extraction_model":{"nullable":true,"type":"string"},"has_api_key":{"description":"Whether an Anthropic key is configured","type":"boolean"},"has_chat_key":{"description":"Whether a credential for `chat_base_url` is configured","type":"boolean"},"has_embedding_key":{"description":"Whether an OpenAI embedding key is configured","type":"boolean"},"merge_model":{"nullable":true,"type":"string"}},"title":"LlmConfigResponse","type":"object"},"RetrieveRequest":{"description":"Params for POST /retrieve/:entity. `field` + `op` select the operation (`filter` needs the matched field's value in `value`; `search` needs `query`). Any `tenant_id` in the body is ignored (scope is from the key).","properties":{"field":{"description":"Field to filter on (op=filter).","type":"string"},"limit":{"description":"Page size (clamped to the server max).","type":"integer"},"offset":{"description":"Records to skip (clamped).","type":"integer"},"op":{"description":"Operation.","enum":["filter","search"],"type":"string"},"operation":{"description":"Alias for `op`.","enum":["filter","search"],"type":"string"},"query":{"description":"Search string (op=search).","type":"string"},"value":{"description":"Filter value (op=filter)."}},"title":"RetrieveRequest","type":"object"},"TenantResponse":{"description":"Tenant profile","example":{"email":"admin@example.com","id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","inserted_at":"2026-01-15T10:00:00Z","name":"My Org","settings":{},"slug":"my-org","status":"active","trust_tier":"human_anchored","updated_at":"2026-01-15T10:00:00Z"},"properties":{"email":{"format":"email","type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"name":{"type":"string"},"settings":{"additionalProperties":true,"type":"object"},"slug":{"type":"string"},"status":{"enum":["active","suspended","deactivated","pending_enrollment"],"type":"string"},"trust_tier":{"description":"US-26.7.1 — human_anchored (WebAuthn signup) unlocks the work-breakdown / chain-of-custody surface; agent_rooted (self-signup) is KB-tier only.","enum":["human_anchored","agent_rooted"],"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"TenantResponse","type":"object"},"SkillVersionResponse":{"description":"Skill version resource","example":{"changelog":"Initial version","created_by":"orchestrator-main","id":"c9d0e1f2-a3b4-5678-cdef-789012345678","inserted_at":"2026-01-15T10:00:00Z","metadata":{},"prompt_text":"You are reviewing code for correctness and adherence to acceptance criteria...","skill_id":"b8c9d0e1-f2a3-4567-bcde-678901234567","version":1},"properties":{"changelog":{"nullable":true,"type":"string"},"created_by":{"nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"prompt_text":{"type":"string"},"skill_id":{"format":"uuid","type":"string"},"version":{"type":"integer"}},"title":"SkillVersionResponse","type":"object"},"BulkVerifyRequest":{"description":"Bulk verify stories","example":{"stories":[{"notes":"All ACs met","story_id":"e5f6a7b8-c9d0-1234-efab-345678901234"}]},"properties":{"stories":{"items":{"properties":{"notes":{"nullable":true,"type":"string"},"story_id":{"format":"uuid","type":"string"}},"type":"object"},"type":"array"}},"required":["stories"],"title":"BulkVerifyRequest","type":"object"},"ChannelPostResponse":{"description":"A coordination channel post","properties":{"created":{"description":"true when a new post was appended or a session slot upserted; false when a keyless idempotency_key write deduplicated to an existing post (US-40.B2)","type":"boolean"},"meta":{"description":"Write-path provenance markers. Present on created/updated/deduplicated responses. key_source is 'derived_from_body' when the server derived the handoff key from the body because no key was sent. session_id_source is 'server_surrogate' when the server minted a unique session id because the proxy supplied none. Both nil on a normal client-driven write.","nullable":true,"properties":{"key_source":{"nullable":true,"type":"string"},"session_id_source":{"nullable":true,"type":"string"}},"type":"object"},"post":{"properties":{"agent_id":{"format":"uuid","type":"string"},"body":{"type":"string"},"expires_at":{"format":"date-time","type":"string"},"host":{"nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"key":{"nullable":true,"type":"string"},"project_id":{"format":"uuid","type":"string"},"refs":{"items":{"additionalProperties":true,"type":"object"},"nullable":true,"type":"array"},"session_id":{"nullable":true,"type":"string"},"tenant_id":{"format":"uuid","type":"string"},"to_capability":{"description":"Advisory surfacing address (spoofable, never authz)","nullable":true,"type":"string"},"to_host":{"description":"Advisory surfacing address (spoofable, never authz)","nullable":true,"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"type":"object"}},"title":"ChannelPostResponse","type":"object"},"RecallContextResponse":{"description":"Merged recall: `data` is the re-ranked union across memory + knowledge (each item tagged `source`, sorted by a heuristically-comparable `score` DESC — `meta.results_ranking` is `heuristic_cross_source`), with the untouched per-source `memory` and `knowledge` envelopes for re-ranking, plus `meta` (counts + degraded flag). Cross-source scores are heuristic, not calibrated: memory `score` is ABSOLUTE cosine similarity in [0,1] (null on the fallback path); knowledge `score` is a POOL-NORMALIZED keyword+semantic score (biases knowledge upward in the default order). The knowledge `article`/`data` items are combined-search SUMMARIES (id/title/category/tags/score + a truncated snippet) — the same shape `/knowledge/search` returns, NOT full bodies or linked references; call `/knowledge/context` for those.","properties":{"data":{"description":"Merged, re-ranked results across both sources.","items":{"properties":{"article":{"description":"Present on `source: knowledge` items — the combined-search summary (id/title/category/tags/score + truncated snippet), the same whitelisted shape `/knowledge/search` returns.","nullable":true,"type":"object"},"memory":{"description":"Present on `source: memory` items (see Memory schema).","nullable":true,"type":"object"},"score":{"format":"float","nullable":true,"type":"number"},"source":{"enum":["memory","knowledge"],"type":"string"}},"type":"object"},"type":"array"},"knowledge":{"description":"The combined-search envelope (data + meta). Each `data` item is the whitelisted summary shape (id/title/category/tags/score + truncated snippet), NOT the raw internal result map.","properties":{"data":{"items":{"type":"object"},"type":"array"},"meta":{"type":"object"}},"type":"object"},"memory":{"description":"The unchanged /memory/recall envelope (data + meta).","properties":{"data":{"items":{"properties":{"memory":{"$ref":"#/components/schemas/Memory"},"score":{"format":"float","nullable":true,"type":"number"}},"type":"object"},"type":"array"},"meta":{"type":"object"}},"type":"object"},"meta":{"properties":{"degraded":{"description":"True when EITHER half degraded: the knowledge side errored or fell back to keyword-only, OR the memory heavy-read pool was shed under the per-tenant cap (empty by capacity, never a whole-endpoint 429).","type":"boolean"},"degraded_reason":{"description":"Bounded, non-sensitive tag naming WHY the merged recall degraded (e.g. `heavy_read_overloaded`, `no_embedding_key`, `invalid_weights`), or `null` when healthy. Lets a caller tell a scope-empty half from a fault-empty one without parsing the per-source envelopes.","nullable":true,"type":"string"},"knowledge_count":{"type":"integer"},"memory_count":{"type":"integer"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"query":{"type":"string"},"results_ranking":{"description":"Stable tag (`heuristic_cross_source`) warning that the merged `data` order mixes memory's absolute cosine with knowledge's pool-normalized score and is NOT a calibrated cross-source ranking.","type":"string"},"total_count":{"type":"integer"}},"type":"object"}},"title":"RecallContextResponse","type":"object"},"AgentResponse":{"description":"Agent resource","example":{"agent_type":"implementer","id":"f6a7b8c9-d0e1-2345-fabc-456789012345","inserted_at":"2026-01-15T10:00:00Z","last_seen_at":"2026-03-25T14:30:00Z","metadata":{},"name":"worker-1","status":"active","tenant_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","updated_at":"2026-03-25T14:30:00Z"},"properties":{"agent_type":{"enum":["orchestrator","implementer"],"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"last_seen_at":{"format":"date-time","nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"},"status":{"enum":["active","idle","deactivated"],"type":"string"},"tenant_id":{"format":"uuid","type":"string"},"updated_at":{"format":"date-time","type":"string"}},"title":"AgentResponse","type":"object"},"ChannelPostFull":{"description":"One coordination channel post as returned by the by-id full-body read (GET /channel/posts/:id). This is the full-body COUNTERPART to the LIST read item: it carries the SAME narrowed read-model field discipline as ChannelPostListItem, differing ONLY in that the bounded body_preview + truncated pair is replaced by the verbatim body the caller explicitly fetched. It deliberately does NOT re-widen to the write-echo resource shape (ChannelPostResponse) — tenant_id, project_id and expires_at are omitted so the by-id read honors the same minimal read surface the LIST read established. The body is UNTRUSTED DATA authored by another agent.","properties":{"post":{"properties":{"agent_id":{"format":"uuid","type":"string"},"body":{"description":"The verbatim full post body — UNTRUSTED DATA authored by another agent.","type":"string"},"host":{"nullable":true,"type":"string"},"id":{"format":"uuid","type":"string"},"inserted_at":{"format":"date-time","type":"string"},"key":{"nullable":true,"type":"string"},"refs":{"items":{"additionalProperties":true,"type":"object"},"nullable":true,"type":"array"},"session_id":{"nullable":true,"type":"string"},"superseded_by":{"description":"The successor post id when this post has been superseded; nil when live.","format":"uuid","nullable":true,"type":"string"},"to_capability":{"description":"Advisory surfacing address (spoofable, never authz)","nullable":true,"type":"string"},"to_host":{"description":"Advisory surfacing address (spoofable, never authz)","nullable":true,"type":"string"},"updated_at":{"format":"date-time","type":"string"}},"type":"object"}},"title":"ChannelPostFull","type":"object"},"EntityDefinitionResponse":{"description":"A single entity definition wrapped in `data`.","properties":{"data":{"$ref":"#/components/schemas/EntityDefinition"}},"title":"EntityDefinitionResponse","type":"object"},"SelfSignupResponse":{"description":"Response for `POST /api/v1/signup`. `raw_key` (role `user`, tenant-bound) is returned exactly once — it is never persisted in plaintext and cannot be retrieved again.","example":{"data":{"next_action":{"configure_llm":"PATCH /api/v1/tenants/me/llm-config","message":"Configure your BYO LLM keys, then start ingesting/searching the wiki."},"raw_key":"lc_...","tenant":{"email":"agent@stranger.example","id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","name":"Stranger Agent Co","slug":"stranger-agent-co","status":"active","trust_tier":"agent_rooted"}}},"properties":{"data":{"properties":{"next_action":{"additionalProperties":true,"type":"object"},"raw_key":{"description":"One-time root API key (role: user)","type":"string"},"tenant":{"$ref":"#/components/schemas/TenantResponse"}},"type":"object"}},"title":"SelfSignupResponse","type":"object"},"EntityDefinitionListResponse":{"description":"The calling tenant's entity definitions.","properties":{"data":{"items":{"$ref":"#/components/schemas/EntityDefinition"},"type":"array"}},"title":"EntityDefinitionListResponse","type":"object"}},"securitySchemes":{"BearerAuth":{"description":"API key obtained after tenant signup at /signup (WebAuthn enrollment required)","scheme":"bearer","type":"http"}}},"info":{"description":"Agent-native project state store for AI development loops. Provides multi-tenant project management, work breakdown, two-tier trust model for story verification, and orchestrator state.","title":"loopctl","version":"1.0.0"},"openapi":"3.0.0","paths":{"/api/v1/entities":{"get":{"callbacks":{},"description":"Lists the calling tenant's entity definitions, ordered by name.","operationId":"LoopctlWeb.ContextRetrieverController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDefinitionListResponse"}}},"description":"Definitions"}},"summary":"List entity definitions","tags":["Context Retriever"]},"post":{"callbacks":{},"description":"Creates a tenant-scoped entity definition (name + typed, allowlisted fields + a backing source). Requires >= user role. `tenant_id` is derived from the key. Returns 201 with the created definition.","operationId":"LoopctlWeb.ContextRetrieverController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDefinitionRequest"}}},"description":"Entity params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDefinitionResponse"}}},"description":"Created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error or entity limit reached"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create an entity definition","tags":["Context Retriever"]}},"/api/v1/admin/tenants/{id}/suspend":{"post":{"callbacks":{},"description":"Suspends a tenant. Returns 422 if already suspended.","operationId":"LoopctlWeb.AdminTenantController.suspend","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Tenant suspended"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Already suspended"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Suspend tenant (admin)","tags":["Admin"]}},"/api/v1/knowledge/walk":{"get":{"callbacks":{},"description":"Returns a random walk of up to `length` published articles visible to the caller starting from `start_id`, following random unvisited link-graph neighbors (no cycles; stops at a dead end). Agent callers see only their own and `shared` articles. Surfaces unexpected connections. Role: agent+.","operationId":"LoopctlWeb.KnowledgeCreativityController.walk","parameters":[{"description":"Starting article UUID (required)","in":"query","name":"start_id","required":false,"schema":{"type":"string"}},{"description":"Walk steps (default 4, max 25)","in":"query","name":"length","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Sequence of articles in the random walk","type":"array"},"meta":{"properties":{"count":{"description":"Steps in the walk","type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Walk"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Article not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Random walk through the link graph","tags":["Knowledge Wiki"]}},"/api/v1/projects/{project_id}/knowledge/stats":{"get":{"callbacks":{},"description":"Returns aggregate article counts — `total`, `by_category`, and `by_status` — computed with cheap COUNT(*) GROUP BY queries (no article metadata is loaded). Agent callers see only their own articles and `shared` articles; higher roles see all. Counts span all statuses (draft, published, archived, superseded); use `by_status` to see the split. When called via GET /projects/:project_id/knowledge/stats, counts both tenant-wide and project-specific articles within visibility. Role: agent+.","operationId":"LoopctlWeb.KnowledgeStatsController.stats (2)","parameters":[{"description":"Project UUID (optional, for project-scoped counts)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"by_category":{"description":"Count per category","type":"object"},"by_status":{"description":"Count per status","type":"object"},"total":{"type":"integer"}},"type":"object"}}},"description":"Knowledge stats"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Knowledge stats","tags":["Knowledge Wiki"]}},"/api/v1/epic_dependencies":{"post":{"callbacks":{},"description":"Creates a dependency: epic_id depends on depends_on_epic_id.","operationId":"LoopctlWeb.EpicDependencyController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"depends_on_epic_id":{"format":"uuid","type":"string"},"epic_id":{"format":"uuid","type":"string"}},"required":["epic_id","depends_on_epic_id"],"type":"object"}}},"description":"Dependency params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Dependency created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Conflict"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create epic dependency","tags":["Dependencies"]}},"/api/v1/stories/{id}/unclaim":{"post":{"callbacks":{},"description":"Agent releases a story back to pending.","operationId":"LoopctlWeb.StoryStatusController.unclaim","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story unclaimed"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not assigned agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Unclaim story","tags":["Progress"]}},"/api/v1/knowledge/novelty":{"post":{"callbacks":{},"description":"For each idea, returns `novelty_score` = cosine distance to its nearest prior proposal (0 = identical to existing work, higher = more novel, up to 2.0 = opposite vectors; `null` when the idea text is blank, no priors exist, or embedding fails). Each idea's text is embedded on the fly. Priors default to published articles tagged `proposal` visible to the caller (agent callers see only their own and `shared` articles; override with `prior_tag`). `meta.prior_count` is the number of embedded visible priors actually compared against (0 ⇒ every score is null). Body: provide the ideas as EITHER `texts: [\"...\", ...]` (strings) OR `ideas: [...]` where each element is a string or an object `{text|title/spark/thesis,...}`; all forms are coerced to ideas. Optional `prior_tag` (default `proposal`) selects the prior corpus. Returns `{data: [{...idea, novelty_score}], meta: {prior_count}}`. Role: agent+.","operationId":"LoopctlWeb.KnowledgeCreativityController.novelty","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"ideas":{"description":"Strings or objects ({text|title/spark/thesis,...})","type":"array"},"prior_tag":{"type":"string"},"texts":{"description":"Alternative to `ideas`: a list of idea strings (#152 AC shape)","items":{"type":"string"},"type":"array"}},"type":"object"}}},"description":"Ideas to score (texts:[string] or ideas:[string|object])","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Ideas with novelty_score (cosine distance to nearest prior, [0,2] or nil)","type":"array"},"meta":{"properties":{"prior_count":{"description":"Number of embedded priors compared against (0 ⇒ all scores null)","type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Scored ideas"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Novelty score (distance to nearest prior)","tags":["Knowledge Wiki"]}},"/api/v1/webhooks/{id}/deliveries":{"get":{"callbacks":{},"description":"Lists recent delivery attempts for a webhook.","operationId":"LoopctlWeb.WebhookController.deliveries","parameters":[{"description":"Webhook UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Delivery list"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List webhook deliveries","tags":["Webhooks"]}},"/api/v1/story_dependencies":{"post":{"callbacks":{},"description":"Creates a dependency: story_id depends on depends_on_story_id.","operationId":"LoopctlWeb.StoryDependencyController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"depends_on_story_id":{"format":"uuid","type":"string"},"story_id":{"format":"uuid","type":"string"}},"required":["story_id","depends_on_story_id"],"type":"object"}}},"description":"Dependency params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Dependency created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Conflict"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create story dependency","tags":["Dependencies"]}},"/api/v1/projects/{id}/export":{"get":{"callbacks":{},"description":"Exports a complete project as JSON.","operationId":"LoopctlWeb.ImportExportController.export_project","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportResponse"}}},"description":"Export data"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Export project","tags":["Import/Export"]}},"/api/v1/tenants/{id}/rotate-audit-key/challenge":{"post":{"callbacks":{},"description":"Step 1 of the challenge-bound WebAuthn reauthentication ceremony. Issues an authentication challenge bound to the tenant's enrolled root authenticators, stores it server-side (single-use, short TTL) and returns the opaque `challenge_id`, the base64url challenge bytes, and the allowed credential ids. Requires user role and tenant ownership.","operationId":"LoopctlWeb.TenantAuditKeyController.challenge","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReauthChallengeResponse"}}},"description":"Reauth challenge"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"No enrolled authenticators"}},"summary":"Issue an audit-key rotation reauth challenge","tags":["Tenants"]}},"/api/v1/knowledge/embeddings/reembed":{"post":{"callbacks":{},"description":"Enqueues the AC-41.1.10 re-embed onto `target_dimension`. Recall keeps serving at the CURRENT dimension for the whole run; the tenant's recorded dimension is flipped and the stale-dimension rows dropped only after the whole corpus (articles, per-tenant system-article materializations AND agent memories) is present at the target. One-time and cost-bearing: it re-bills the tenant for the entire corpus. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeEmbeddingController.reembed","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"target_dimension":{"type":"integer"}},"required":["target_dimension"],"type":"object"}}},"description":"Re-embed request","required":false},"responses":{"202":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Enqueued"}},"summary":"Re-embed the tenant's corpus onto a new dimension","tags":["Knowledge Wiki"]}},"/api/v1/projects/{project_id}/ui-tests":{"get":{"callbacks":{},"description":"Lists all UI test runs for a project with optional status filter.","operationId":"LoopctlWeb.UiTestController.index","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Max results (default 20)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"UI test run list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List UI test runs","tags":["UI Tests"]},"post":{"callbacks":{},"description":"Creates a new UI test run for a project with status in_progress.","operationId":"LoopctlWeb.UiTestController.create","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartUiTestRequest"}}},"description":"Start UI test params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"UI test run created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Start a UI test run","tags":["UI Tests"]}},"/api/v1/orchestrator/state/{project_id}/history":{"get":{"callbacks":{},"description":"Returns version history derived from audit log entries.","operationId":"LoopctlWeb.OrchestratorStateController.history","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"State key filter","in":"query","name":"state_key","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"State history"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get orchestrator state history","tags":["Orchestrator"]}},"/api/v1/stories/{id}/start":{"post":{"callbacks":{},"description":"Agent starts work on an assigned story.","operationId":"LoopctlWeb.StoryStatusController.start (2)","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story started"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not assigned agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Start story","tags":["Progress"]}},"/api/v1/projects/{id}/dependency_graph":{"get":{"callbacks":{},"description":"Returns the full dependency graph for a project.","operationId":"LoopctlWeb.DependencyGraphController.graph","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Dependency graph"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get dependency graph","tags":["Dependencies"]}},"/api/v1/kb-scopes/{id}":{"delete":{"callbacks":{},"description":"Archives (soft-deletes, reversible) a kind: kb project scope owned by the tenant. Agent+ role and NOT human-anchor gated (extends #331 / create_kb_scope). Archiving frees the scope's slot in the tenant's max_projects budget so agents can reclaim KB-scope capacity. A kind: work project is rejected (422) — archiving a work project remains human-anchored via DELETE /projects/:id.","operationId":"LoopctlWeb.ProjectController.archive_kb_scope","parameters":[{"description":"KB scope (project) UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Archived KB scope"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not a KB scope"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Archive a knowledge-only project scope","tags":["Projects"]}},"/api/v1/agents/register":{"post":{"callbacks":{},"description":"Self-registers a new agent. Requires an API key with agent role.","operationId":"LoopctlWeb.AgentController.register","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentRegisterRequest"}}},"description":"Agent params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent created"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Agent already registered"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Register agent","tags":["Agents"]}},"/api/v1/projects/{project_id}/knowledge/index":{"get":{"callbacks":{},"description":"Returns a lightweight catalog of published articles grouped by category. Each article object includes only the projected fields (default id, title, category — see `fields`). Agent callers see only articles they own (when `visibility` is `private` or `owner`) or marked `shared`; higher roles see all articles. When called via GET /projects/:project_id/knowledge/index, includes both tenant-wide and project-specific articles. Honors category/tags filters and offset/limit pagination (default limit 1000, max 1000) with deterministic ordering over the filtered set. `meta.categories` reports per-category counts within the caller's visible articles. Use `fields` to control the projection (default id,title,category; request tags/status/updated_at explicitly) to keep the payload small. Role: agent+.","operationId":"LoopctlWeb.KnowledgeIndexController.index (2)","parameters":[{"description":"Project UUID (optional, for project-scoped index)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by category (pattern, convention, decision, finding, reference). Returns 400 for an unknown category.","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Comma-separated tags (match mode set by `match`, default ANY)","in":"query","name":"tags","required":false,"schema":{"type":"string"}},{"description":"Tag match mode: any (default, OR) or all (AND — carries every listed tag)","in":"query","name":"match","required":false,"schema":{"type":"string"}},{"description":"Filter to articles with this source_type (by-source enumeration)","in":"query","name":"source_type","required":false,"schema":{"type":"string"}},{"description":"Filter to articles with this source_id UUID (by-source enumeration). A malformed id matches nothing.","in":"query","name":"source_id","required":false,"schema":{"type":"string"}},{"description":"Max articles to return (default 1000, max 1000). A limit above the max is clamped to the maximum — never rejected — so pagination stays complete.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Articles to skip for pagination (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Comma-separated projection (id, title, category, tags, status, updated_at). Default id,title,category. `id` and `category` are always included (category is the grouping key). Returns 400 for unknown fields.","in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Opaque KEYSET cursor for drift-free enumeration of the index (US-27.9b). To use cursor pagination, pass an empty string (`cursor=`) on the FIRST request to opt into the keyset path (which orders by `inserted_at ASC, id ASC`); then follow `meta.next_cursor` verbatim on subsequent requests. Omitting the `cursor` parameter entirely uses the legacy offset path (orders by `category, updated_at DESC, id`), which does not emit `next_cursor`. Do not mix the two paths mid-enumeration, as the sort order differs. The keyset path honors the same category/tags/source filters and is the drift-free way to walk a tag or a source to exhaustion under concurrent writes. The cursor is integrity-protected and tenant-bound — a tampered/forged cursor is rejected with 400.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Articles grouped by category","type":"object"},"meta":{"properties":{"categories":{"type":"object"},"fields":{"items":{"type":"string"},"type":"array"},"has_more":{"type":"boolean"},"limit":{"type":"integer"},"offset":{"type":"integer"},"total_count":{"type":"integer"},"truncated":{"type":"boolean"}},"type":"object"}},"type":"object"}}},"description":"Knowledge index"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Knowledge index","tags":["Knowledge Wiki"]}},"/api/v1/channel/posts":{"get":{"callbacks":{},"description":"Returns LIVE coordination posts for a project's channel (a channel IS a project_id), newest-first (inserted_at DESC, seq DESC). Agent+ role, tenant-scoped from the verified key — project_id is a query param but the tenant is NEVER taken from params. ORACLE-SAFE: a project_id belonging to another tenant, or a nonexistent one, returns 200 with an empty list — identical to an owned-but-empty channel, never a 404. Only non-expired posts are returned (expires_at > now, independent of the TTL sweep). Bodies are BOUNDED previews (body_preview + truncated), never full bodies — fetch a full body via GET /channel/posts/:id. `since` (ISO8601) returns only posts touched after that instant (delta read, with a bounded commit-lag look-back so a late-committing earlier row is re-delivered — AT-LEAST-ONCE with a small overlap the consumer dedups). `cursor` pages older history via the keyset `(inserted_at, seq)`: follow `meta.next_cursor` verbatim until it is null (exhausted). A cursor takes precedence over `since`. A tampered or cross-tenant cursor is rejected with 400. `limit` defaults to 25 and is clamped to 100.","operationId":"LoopctlWeb.ChannelPostController.index","parameters":[{"description":"The channel — a project the caller's tenant owns","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Full ISO8601 INSTANT (e.g. 2026-07-18T00:00:00Z or 2026-07-18T00:00:00); return only posts touched after it (delta read). A date-only value (e.g. 2026-07-18) is the wrong granularity and is IGNORED — the whole live channel is returned, not a delta. Supply a full instant.","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"Opaque keyset paging token. Omit for the newest page, then follow meta.next_cursor verbatim to page OLDER history by (inserted_at, seq). Tenant-bound and integrity-signed; a tampered or cross-tenant cursor returns 400. Takes precedence over `since`.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Max posts (default 25, clamped to 100)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/ChannelPostListItem"},"type":"array"},"meta":{"properties":{"count":{"type":"integer"},"has_more":{"description":"True when the limit truncated the result — more live matching posts exist","type":"boolean"},"limit":{"type":"integer"},"next_cursor":{"description":"Opaque keyset paging token for the next OLDER page; null when history is exhausted or in delta (`since`) mode. Follow it verbatim as `?cursor=`.","nullable":true,"type":"string"}},"type":"object"}},"type":"object"}}},"description":"Channel posts"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid or tampered cursor"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Read a repo coordination channel (channel_recent)","tags":["Coordination"]},"post":{"callbacks":{},"description":"Posts one short, attributed coordination message to a project's channel (a channel IS a project_id). Agent+ role, NOT behind the human-anchor tier (coordination surface, owner decision #331). tenant_id and agent_id are stamped server-side from the verified key — any agent_id/tenant_id in the body is ignored. With a `key` the write upserts the caller's own per-session working-state slot (200); without a key it is a new append-only row (201). On the keyless path an OPTIONAL idempotency_key makes a retried append idempotent: a repeat write with the same (tenant, project, agent, idempotency_key) returns the EXISTING post (200, created:false) instead of appending a duplicate. expires_at is set server-side to now + 30 days.","operationId":"LoopctlWeb.ChannelPostController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelPostRequest"}}},"description":"Channel post params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelPostResponse"}}},"description":"Session slot upserted in place, or a keyless idempotent write deduplicated to the existing post (created:false)"},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelPostResponse"}}},"description":"Post created"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Agent identity required"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error or unknown/cross-tenant project"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Post to a repo coordination channel","tags":["Coordination"]}},"/api/v1/tenants/{id}/authenticators/{auth_id}":{"delete":{"callbacks":{},"description":"Verifies the WebAuthn assertion against the STORED revoke_authenticator challenge, then race-safe deletes the target authenticator. Refuses (409) to remove the LAST authenticator on a human_anchored tenant — no auto-downgrade. Requires user role and tenant ownership.","operationId":"LoopctlWeb.TenantAuthenticatorController.delete","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Authenticator UUID","in":"path","name":"auth_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevokeAuthenticatorRequest"}}},"description":"WebAuthn assertion","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevokeAuthenticatorResponse"}}},"description":"Authenticator revoked"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"WebAuthn required or failed"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Last authenticator"}},"summary":"Revoke an enrolled authenticator","tags":["Tenants"]}},"/health":{"get":{"callbacks":{},"description":"Returns application health including database and Oban status.","operationId":"LoopctlWeb.HealthController.check","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Healthy"},"503":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Degraded"}},"security":[],"summary":"Health check","tags":["Health"]}},"/api/v1/egress/repin":{"post":{"callbacks":{},"description":"Role :agent. The :pin_stale remediation — target deployments change IP routinely, so recovery must NOT require a role :user write. The host must be one the tenant actually uses: a currently-resolved endpoint for the scope, or one of the tenant's declared trusted endpoints (both readable at :agent via GET /egress/posture). An arbitrary host is refused with 422 host_not_repinnable — returning a locality verdict for any host would be a deployment-allowlist / internal-DNS membership oracle at the lowest-privileged role.","operationId":"LoopctlWeb.EgressController.repin","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Repin request","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Re-pinned"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Repin failed or host not repinnable"}},"summary":"Re-pin a host after a :pin_stale refusal","tags":["Egress"]}},"/api/v1/stories/{id}/report-done":{"post":{"callbacks":{},"description":"A DIFFERENT agent (reviewer) reports story as done. The implementing agent cannot call this (chain-of-custody). Optionally includes an artifact report and/or a token usage record.","operationId":"LoopctlWeb.StoryStatusController.report (2)","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"artifact":{"description":"Optional artifact report to attach to this story","properties":{"artifact_type":{"type":"string"},"details":{"additionalProperties":true,"type":"object"},"exists":{"type":"boolean"},"path":{"type":"string"}},"type":"object"},"token_usage":{"description":"Optional token usage to report alongside the story completion. When provided, creates a token_usage_report record for this story.","properties":{"cost_millicents":{"description":"Cost in millicents (1/1000 of a cent)","minimum":0,"type":"integer"},"input_tokens":{"description":"Input tokens consumed","minimum":0,"type":"integer"},"model_name":{"description":"LLM model name","example":"claude-opus-4-5","minLength":1,"type":"string"},"output_tokens":{"description":"Output tokens consumed","minimum":0,"type":"integer"},"phase":{"description":"Work phase (default: other)","enum":["planning","implementing","reviewing","other"],"type":"string"},"session_id":{"description":"Optional session identifier","nullable":true,"type":"string"}},"type":"object"}},"type":"object"}}},"description":"Report params (optional artifact and token_usage)","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story reported done"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition or self-report blocked"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Report story done","tags":["Progress"]}},"/api/v1/knowledge/export":{"get":{"callbacks":{},"description":"Streams published articles as an Obsidian-compatible gzipped tar archive. Files are organized as `{category}/{slug}-{short_id}.md` (the id suffix guarantees collision-free paths) with YAML frontmatter, [[wikilinks]], and a root `_index.md`. Only published articles are included. When called via GET /projects/:project_id/knowledge/export, includes both tenant-wide and project-specific articles. Bounded memory, no article-count cap, fail-closed on mid-stream error. Each article's related-link list is capped at export_max_links_per_article (default 100) per direction; a capped article carries `links_truncated: true` in its frontmatter. Role: user+.","operationId":"LoopctlWeb.KnowledgeExportController.export (2)","parameters":[{"description":"Project UUID (optional, for project-scoped export)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/gzip":{"schema":{"format":"binary","type":"string"}}},"description":"Obsidian .tar.gz archive (chunked)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Too many concurrent exports"}},"summary":"Export knowledge as a streamed Obsidian .tar.gz","tags":["Knowledge Wiki"]}},"/api/v1/channel/posts/{id}/graduate":{"post":{"callbacks":{},"description":"Promotes ONE coordination post into the durable Knowledge wiki (US-40.E1). CONTENT-SELECTIVE: this is for a genuinely REUSABLE finding that has no external tracker — NOT the general handoff-durability answer. A transient directive (e.g. 'run this SQL') should be LEFT TO EXPIRE (posts auto-expire in 30 days); a reusable lesson graduates. There is NO automatic graduation — this is always an explicit, deliberate agent call. `title` is REQUIRED; the body is carried from the post, project_id is carried over, `tags` are optional. Agent+ role, project-scoped by membership (US-40.D3), NOT human-anchor gated (coordination surface, owner decision #331). Reuses Knowledge's EXISTING guardrails — the SEMANTIC NOVELTY gate (a near-duplicate returns 200 deduplicated and creates nothing) plus an explicit secret scan over the body (a denylisted credential shape returns 422 and nothing lands) — never a bypass. The article carries source_type 'channel_graduation' + source_id = the post id, attributed to the graduating agent. The source post is KEPT (the 30-day TTL sweep reclaims it); it is NOT marked graduated. Rate-bounded by the per-write cap so it cannot bulk-flood Knowledge from the channel.","operationId":"LoopctlWeb.ChannelPostController.graduate","parameters":[{"description":"The post id — must belong to the caller's tenant","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"category":{"default":"finding","description":"Optional article category; defaults to 'finding' (a reusable lesson) when omitted","type":"string"},"tags":{"description":"Optional topical tags for the article","items":{"type":"string"},"type":"array"},"title":{"description":"The Knowledge article title (required)","type":"string"}},"required":["title"],"type":"object"}}},"description":"Graduation params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"A near-duplicate already exists; nothing created (deduplicated)"},"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Article created from the post"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Agent identity required"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Post not found (nonexistent, malformed id, in another tenant, or not a project member)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Title conflicts with an existing active article that has different content"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error, or the title/tags/body carry a denylisted secret"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Novelty gate temporarily unavailable (embedding backend down); nothing graduated, retry"}},"summary":"Graduate a coordination post into the durable Knowledge wiki","tags":["Coordination"]}},"/api/v1/stories":{"get":{"callbacks":{},"description":"Lists all stories for a project. Requires agent+ role. Supports filtering by status and epic. Uses offset-based pagination (limit/offset).","operationId":"LoopctlWeb.StoryController.index_by_project","parameters":[{"description":"Project UUID","in":"query","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"Filter by agent status","example":"pending","in":"query","name":"agent_status","required":false,"schema":{"type":"string"}},{"description":"Filter by verified status","example":"unverified","in":"query","name":"verified_status","required":false,"schema":{"type":"string"}},{"description":"Filter to a specific epic UUID","in":"query","name":"epic_id","required":false,"schema":{"type":"string"}},{"description":"Max stories to return (default 100, max 500)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Number of stories to skip (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/StoryResponse"},"type":"array"},"meta":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total_count":{"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Story list"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing project_id"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List stories by project","tags":["Stories"]}},"/api/v1/knowledge/ingestion-jobs":{"get":{"callbacks":{},"description":"Returns content ingestion jobs for the current tenant, newest first, with offset/limit pagination over the full history (advance `offset` by `meta.limit` to enumerate to completeness). Optional `since_days` narrows to a recent window. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeIngestionController.index","parameters":[{"description":"Max jobs per page (default 20). A larger value is clamped, never rejected.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Rows to skip (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Optional: only jobs from the last N days (default: all history)","in":"query","name":"since_days","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Page of ingestion jobs","type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Ingestion jobs list"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List ingestion jobs","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/stats":{"get":{"callbacks":{},"description":"Returns aggregate article counts — `total`, `by_category`, and `by_status` — computed with cheap COUNT(*) GROUP BY queries (no article metadata is loaded). Agent callers see only their own articles and `shared` articles; higher roles see all. Counts span all statuses (draft, published, archived, superseded); use `by_status` to see the split. When called via GET /projects/:project_id/knowledge/stats, counts both tenant-wide and project-specific articles within visibility. Role: agent+.","operationId":"LoopctlWeb.KnowledgeStatsController.stats","parameters":[{"description":"Project UUID (optional, for project-scoped counts)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"by_category":{"description":"Count per category","type":"object"},"by_status":{"description":"Count per status","type":"object"},"total":{"type":"integer"}},"type":"object"}}},"description":"Knowledge stats"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Knowledge stats","tags":["Knowledge Wiki"]}},"/api/v1/admin/tenants/{id}/clear-halt":{"post":{"callbacks":{},"description":"Step 2 of the challenge-bound WebAuthn reauthentication ceremony. Verifies the assertion against the STORED challenge from step 1 (challenge binding, origin, RP-ID, signature against the enrolled COSE key, sign-counter regression) and, on success, clears the tenant's custody halt. The assertion is single-use — one challenge authorizes exactly one attempt. Requires superadmin.","operationId":"LoopctlWeb.AdminTenantController.clear_halt","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReauthAssertionRequest"}}},"description":"WebAuthn assertion","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Halt cleared"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"WebAuthn required or failed"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"}},"summary":"Clear a tenant custody halt (break-glass, admin)","tags":["Admin"]}},"/api/v1/api_keys/{id}":{"delete":{"callbacks":{},"description":"Revokes an API key (sets revoked_at). Requires user role.","operationId":"LoopctlWeb.ApiKeyController.delete","parameters":[{"description":"API key UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyResponse"}}},"description":"API key revoked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Revoke API key","tags":["Auth"]}},"/api/v1/articles/{id}/publish":{"post":{"callbacks":{},"description":"Transitions article from draft to published. Returns 422 if the transition is invalid. Role: orchestrator+.","operationId":"LoopctlWeb.ArticleWorkflowController.publish","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Published article"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Publish article","tags":["Knowledge Wiki"]}},"/api/v1/api_keys/{id}/rotate":{"post":{"callbacks":{},"description":"Creates a new key with the same name/role and sets a grace period on the old key.","operationId":"LoopctlWeb.ApiKeyController.rotate","parameters":[{"description":"API key UUID to rotate","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"grace_period_hours":{"description":"Grace period in hours (default: 24)","type":"integer"}},"type":"object"}}},"description":"Rotation params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Rotated key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Cannot rotate"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Rotate API key","tags":["Auth"]}},"/api/v1/skills/{id}/versions/{version}/results":{"get":{"callbacks":{},"description":"Returns skill results for a specific version.","operationId":"LoopctlWeb.SkillController.version_results","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Version number","in":"path","name":"version","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Version results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid version"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get version results","tags":["Skills"]}},"/api/v1/webhooks":{"get":{"callbacks":{},"description":"Lists all webhook subscriptions for the authenticated tenant.","operationId":"LoopctlWeb.WebhookController.index","parameters":[{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/WebhookResponse"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Webhook list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List webhooks","tags":["Webhooks"]},"post":{"callbacks":{},"description":"Creates a new webhook subscription. Returns the signing secret once.","operationId":"LoopctlWeb.WebhookController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreateRequest"}}},"description":"Webhook params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookResponse"}}},"description":"Webhook created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create webhook","tags":["Webhooks"]}},"/api/v1/articles/{id}/unpublish":{"post":{"callbacks":{},"description":"Transitions article from published back to draft. Returns 422 if the transition is invalid. Role: user+.","operationId":"LoopctlWeb.ArticleWorkflowController.unpublish","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Unpublished article"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Unpublish article","tags":["Knowledge Wiki"]}},"/api/v1/projects/{project_id}/articles":{"get":{"callbacks":{},"description":"Lists articles with optional filters and pagination. When called via GET /projects/:project_id/articles, project_id is set from path. Unlike search (which ranks and returns **published** articles only and lags writes while embeddings index), this is the **lag-free, all-status** read of the DB of record — use it for dedup/idempotency/repair (\"does an article with this tag/source/idempotency_key exist?\"). `meta.total_count` is the exact filtered count. **Returns a body-less summary by default** (safe to enumerate up to limit=1000); pass `include_body=true` to also return `body`, which bounds the page by a ~5 MB serialized-body budget and adds `meta.next_offset`/`meta.has_more`/`meta.byte_truncated` for continuation. For a single full body use GET /articles/:id. Role: agent+.","operationId":"LoopctlWeb.ArticleController.index (2)","parameters":[{"description":"Filter by category (pattern|convention|decision|finding|reference)","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Filter by status (draft|published|archived|superseded)","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Filter by tags (comma-separated). Match mode set by `match` (default ANY).","in":"query","name":"tags","required":false,"schema":{"type":"string"}},{"description":"Tag match mode: `any` (default, OR — overlaps any listed tag) or `all` (AND — carries every listed tag, e.g. tags=book,hub&match=all = \"book hubs\").","in":"query","name":"match","required":false,"schema":{"type":"string"}},{"description":"Filter by source_type","in":"query","name":"source_type","required":false,"schema":{"type":"string"}},{"description":"Filter by source_id","in":"query","name":"source_id","required":false,"schema":{"type":"string"}},{"description":"Filter by exact idempotency_key (lag-free existence check)","in":"query","name":"idempotency_key","required":false,"schema":{"type":"string"}},{"description":"Max results per page (default 20, max 1000). A limit above the max is clamped to the maximum — never rejected — so offset pagination stays complete.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Records to skip","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Include full article body (default false). When true the page is bounded by a ~5 MB serialized-body budget and may return fewer than `limit` rows; continue via `meta.next_offset` while `meta.has_more` is true.","in":"query","name":"include_body","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"type":"object"}},"type":"object"}}},"description":"Article list"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid filter value (e.g. unknown status/category) — body lists allowed values"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List articles","tags":["Knowledge Wiki"]},"post":{"callbacks":{},"description":"Creates a tenant-wide or project-scoped article. When called via POST /projects/:project_id/articles, project_id is set from path. Articles are **published immediately by default** (visible in search/index/context) for every role, including agent. To stage an article for later review instead, pass `draft: true` (or `status: \"draft\"`); the response `note` says which outcome occurred. The initial status is set by the server — a caller-supplied `status` is ignored except that `status: \"draft\"` is honoured as the draft opt-in (so archived/superseded can't be conjured at create time; those are workflow transitions). Role: agent+.","operationId":"LoopctlWeb.ArticleController.create (2)","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"body":{"type":"string"},"category":{"enum":["pattern","convention","decision","finding","reference"],"type":"string"},"draft":{"description":"Stage as a draft instead of publishing on create. Default false (article is published immediately). Equivalent alias: status: \"draft\". Publishing a staged draft afterwards (POST /articles/:id/publish) requires orchestrator role; publish-on-create does not. (Note: the ingestion path has the OPPOSITE polarity — POST /knowledge/ingest is draft-by-default; pass publish: true there.)","type":"boolean"},"idempotency_key":{"description":"Optional stable per-article key for idempotent capture (max 255). Re-creating with the same key is a no-op that returns a REFERENCE to the existing article (200, `deduplicated: true`, id only — not its body) — regardless of the body sent, and ahead of the title-conflict check; a changed title/body is NOT applied (PATCH /articles/:id to change it). Use a HIGH-ENTROPY value (e.g. a content hash): it is a per-tenant lookup key, not a secret. Distinct from source_type/source_id, which identify a shared source. Set at create time only (ignored by PATCH); applies to tenant-scoped articles.","nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"source_id":{"format":"uuid","nullable":true,"type":"string"},"source_type":{"nullable":true,"type":"string"},"tags":{"items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["title","body","category"],"type":"object"}}},"description":"Article params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Idempotent dedup, returned unchanged with `deduplicated: true`: either an active article with the same title and an identical body exists, OR an article with the same `idempotency_key` exists (in which case a changed title/body is NOT applied). The `note` says which."},"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Article created (response includes a `note`; `status` is published unless draft)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"System scope requested without superadmin role"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Title taken by an article with different content"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create article","tags":["Knowledge Wiki"]}},"/api/v1/projects/{project_id}/ui-tests/{id}":{"get":{"callbacks":{},"description":"Returns a single UI test run with all findings.","operationId":"LoopctlWeb.UiTestController.show","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"UI test run UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"UI test run"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get a UI test run","tags":["UI Tests"]}},"/api/v1/egress/trusted-endpoints":{"get":{"callbacks":{},"description":"Role :agent. Every entry is labelled 'tenant-declared (unverified attestation), not network-local' — loopctl does not prove the declaring tenant owns the host.","operationId":"LoopctlWeb.EgressController.list_trusted","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Declared endpoints"}},"summary":"List tenant-declared trusted endpoints","tags":["Egress"]},"post":{"callbacks":{},"description":"Role :user ONLY. PUBLIC addresses only (enforced at write time AND again at pin time), purpose-scoped (inference, webhook and/or ingest), vendor hosts excluded. A declaration carves NOTHING out of the SSRF denylist — only the operator deployment allowlist can do that, and it has no route at any role.","operationId":"LoopctlWeb.EgressController.declare_trusted","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Trusted endpoint declaration","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Endpoint declared"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Insufficient role"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rejected declaration"}},"summary":"Declare a tenant-trusted endpoint","tags":["Egress"]}},"/api/v1/cost-anomalies/{id}":{"patch":{"callbacks":{},"description":"Marks a cost anomaly as resolved.","operationId":"LoopctlWeb.CostAnomalyController.update","parameters":[{"description":"Anomaly UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"description":"Confirmation of resolution","properties":{"cost_anomaly":{"properties":{"id":{"format":"uuid","type":"string"},"resolved":{"example":true,"type":"boolean"},"updated_at":{"format":"date-time","type":"string"}},"type":"object"}},"type":"object"}}},"description":"Anomaly resolved"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Anomaly not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Resolve cost anomaly","tags":["Token Efficiency"]}},"/api/v1/stories/bulk/reject":{"post":{"callbacks":{},"description":"Orchestrator rejects multiple stories with reasons.","operationId":"LoopctlWeb.BulkOperationsController.reject","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkRejectRequest"}}},"description":"Reject params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResultResponse"}}},"description":"Results"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid input"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Bulk reject stories","tags":["Progress"]}},"/api/v1/knowledge/bulk-publish":{"post":{"callbacks":{},"description":"Publishes draft articles **partial-success** style. Every valid draft is published; each other id gets a per-id `outcome` instead of failing the whole call: `published`; `skipped` (with `reason` `already_published` — idempotent — or `not_publishable_from_archived`/`not_publishable_from_superseded`); `not_found` (no such article in this tenant, incl. malformed ids); or `errored` (`reason` `publish_failed`). **A 200 does NOT mean everything published** — inspect `meta.counts`: a request of all already-published or not-found ids still returns 200 with `count: 0`. Duplicate ids are de-duplicated. There is **no 100-id cap** (auto-chunked server-side, each chunk its own transaction; a failing chunk is retried row-by-row so one bad row never sinks the rest), but a single request is bounded to 5000 ids (400 above that). `meta.count` = number actually published; `meta.counts` has requested/published/skipped/not_found/errored; `meta.results` is the per-id breakdown in request order. Role: user+.","operationId":"LoopctlWeb.ArticleWorkflowController.bulk_publish","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"article_ids":{"items":{"format":"uuid","type":"string"},"type":"array"}},"required":["article_ids"],"type":"object"}}},"description":"Bulk publish params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"type":"object"}},"type":"object"}}},"description":"Bulk publish result (partial success; see meta.results / meta.counts)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request (empty article_ids)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Bulk publish articles","tags":["Knowledge Wiki"]}},"/api/v1/tenants/{id}/authenticators":{"post":{"callbacks":{},"description":"Step 2 of the opt-in trust-tier upgrade ceremony. Two-phase: consumes the registration challenge (and, if already human_anchored, verifies a fresh add_authenticator assertion) FIRST, then verifies the attestation, then atomically inserts the authenticator and guard-flips trust_tier. Requires user role and tenant ownership. Not tier-gated.","operationId":"LoopctlWeb.TenantAuthenticatorController.create","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrollAuthenticatorRequest"}}},"description":"Enrollment attestation","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthenticatorEnrollResponse"}}},"description":"Authenticator enrolled"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Challenge/assertion/attestation invalid"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too many authenticators"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation failed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited"}},"summary":"Complete WebAuthn authenticator enrollment","tags":["Tenants"]}},"/api/v1/knowledge/articles/{id}/suggested_links":{"get":{"callbacks":{},"description":"Returns ranked link CANDIDATES for an article by embedding similarity within the caller's visible set, **read-only — creates nothing**. Agent callers see only their own and `shared` articles. Excludes the article itself and any already-linked article (either direction, any relationship type); only embedded, published, visible articles are considered. Each candidate is `{id, title, category, similarity_score}`, highest similarity first — POST the one you want as a **typed** link (relates_to/derived_from/contradicts/supersedes) via the article_links API. `threshold` (0–1, default 0.5) is the cosine floor; `limit` (default 5) caps results. Suggestions are approximate-NN over the embedding index: exclusions (already-linked / below-threshold) are applied to the nearest candidate pool, so a densely-linked article may return fewer than `limit` (or none) even if more-distant unlinked articles exist. When that happens the response `meta` carries `recall_truncated: true` (alias `pool_exhausted: true`) so you can tell an INCOMPLETE result (nearest pool was full but filters cut it below `limit`) apart from a genuinely-empty one (no eligible neighbors). Recall is bounded by `hnsw.ef_search` (pgvector default ~40) plus the over-fetch pool; under-fill is expected for densely-linked hubs. Role: agent+.","operationId":"LoopctlWeb.KnowledgeSuggestLinksController.suggest","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Max candidates (default 5)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Cosine similarity floor 0–1 (default 0.5)","in":"query","name":"threshold","required":false,"schema":{"type":"number"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"description":"Recall-completeness signal (US-27.6b). `recall_truncated`/`pool_exhausted` are the same flag: true ⇒ the nearest candidate pool was filled but the already-linked/threshold filters cut the result below `requested`, so more (more-distant) neighbors may exist — distinct from a genuinely-empty result.","properties":{"pool_exhausted":{"type":"boolean"},"recall_truncated":{"type":"boolean"},"requested":{"description":"The requested limit","type":"integer"},"returned":{"description":"How many candidates were returned","type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Suggested links"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Article not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Database unavailable / serialization failure / deadlock — retryable; see Retry-After header"},"504":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Database statement timeout (code db_statement_timeout) — the vector similarity scan exceeded the statement timeout"}},"summary":"Suggest typed link candidates","tags":["Knowledge Wiki"]}},"/api/v1/tenants/{id}/authenticators/challenge":{"post":{"callbacks":{},"description":"Step 1 of the opt-in trust-tier upgrade ceremony (US-26.7.2). Requires user role and tenant ownership. Not tier-gated.","operationId":"LoopctlWeb.TenantAuthenticatorController.challenge","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthenticatorChallengeResponse"}}},"description":"Enrollment challenge"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited"}},"summary":"Issue a WebAuthn enrollment registration challenge","tags":["Tenants"]}},"/api/v1/tenants/me/llm-config":{"get":{"callbacks":{},"description":"Returns the per-operation model choices, whether an Anthropic API key (`has_api_key`) and an embedding API key (`has_embedding_key`) are configured, plus masked last-4 hints. No key itself is ever returned. Role: user+.","operationId":"LoopctlWeb.LlmConfigController.show","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LlmConfigResponse"}}},"description":"LLM config"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get the tenant's LLM configuration","tags":["LLM Configuration"]},"patch":{"callbacks":{},"description":"Sets/rotates the tenant's OWN Anthropic API key + OpenAI embedding key (both stored encrypted, never returned) and the per-operation models. loopctl fronts no cost — the tenant's keys bill the tenant. Role: user+.","operationId":"LoopctlWeb.LlmConfigController.update","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LlmConfigUpdateRequest"}}},"description":"LLM config","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LlmConfigResponse"}}},"description":"Updated LLM config"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Set the tenant's LLM configuration","tags":["LLM Configuration"]}},"/api/v1/custody/claims/{subject_type}/{subject_id}":{"get":{"callbacks":{},"description":"Returns the recorded per-operation egress postures for one article or memory row and the aggregate claim over them. Three states: 'no_claim_recorded', 'claim_pending', or 'claim_recorded' (itself 'complete', 'partial_history' or 'incomplete'). `third_party_egress_on_covered_paths` is false ONLY when every recorded endpoint was NETWORK-local; a tenant-declared (unverified) endpoint yields 'tenant_declared_unverified'. The claim attests ONLY to the endpoints loopctl called for the recorded operations on this row, on the egress paths listed in `coverage` — never to what those endpoints did with the data afterwards, and never to a path listed as uncovered. Role :agent.","operationId":"LoopctlWeb.CustodyClaimController.show","parameters":[{"description":"article | memory","in":"path","name":"subject_type","required":true,"schema":{"type":"string"}},{"description":"Row UUID","in":"path","name":"subject_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Custody claim"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid subject type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"}},"summary":"Egress custody claim for an article or memory","tags":["Custody"]}},"/api/v1/knowledge/hybrid_search":{"post":{"callbacks":{},"description":"Single hybrid retrieval entrypoint (US-31.4). Runs combined keyword+semantic search over the FULL ranked pool, then decides whether a genuinely-answering CURATED source wins. Returns the page plus a `meta.provenance` verdict: `curated` (a governed article actually answers — trust it as canonical, it is guaranteed first in `data`, and `meta.curated_article_id` points at it) or `retrieved` (best semantic/keyword match; `meta.curated_article_id` is null). `meta.confidence` is the winning candidate's absolute score for that provenance class. All underlying combined-search meta (search_mode, fallback, fallback_reason, total_count) is preserved. Prefer this over `GET /knowledge/search` when you want a single trustworthy answer with its provenance, not a ranked list to triage yourself. POST (vs the GET search endpoint) carries the richer JSON body. Role: agent+.","operationId":"LoopctlWeb.KnowledgeHybridSearchController.hybrid_search","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"category":{"description":"Optional: filter by category.","type":"string"},"limit":{"description":"Optional: max results to return (default 10).","type":"integer"},"match":{"description":"Optional: tag match mode — any (default, OR) or all (AND).","enum":["any","all"],"type":"string"},"offset":{"description":"Optional: results to skip for pagination (default 0).","type":"integer"},"project_id":{"description":"Optional: scope to a project UUID.","type":"string"},"query":{"description":"The topic/question to resolve (max 500 characters).","type":"string"},"tags":{"description":"Optional: comma-separated tags to filter by.","type":"string"}},"required":["query"],"type":"object"}}},"description":"Hybrid search request","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Matching articles with scores and snippets. When provenance is `curated`, the winning curated article is first.","type":"array"},"meta":{"properties":{"confidence":{"description":"Winning candidate's absolute score for its provenance class.","type":"number"},"curated_article_id":{"description":"The winning curated article id when provenance=curated, else null.","nullable":true,"type":"string"},"fallback":{"type":"boolean"},"fallback_reason":{"type":"string"},"limit":{"type":"integer"},"offset":{"type":"integer"},"provenance":{"description":"curated = a governed article answers (canonical, first in data); retrieved = best semantic/keyword match.","enum":["curated","retrieved"],"type":"string"},"search_mode":{"type":"string"},"total_count":{"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Hybrid search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Hybrid knowledge retrieval with provenance","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/context":{"get":{"callbacks":{},"description":"Returns full article bodies ranked by combined relevance + recency scoring within the caller's visible set. Agent callers see only their own and `shared` articles. Each result includes one-hop linked article references (max 5 per result) visible to the caller. Falls back to keyword-only search if embedding generation fails. Agent role is forced to published articles. Role: agent+.","operationId":"LoopctlWeb.KnowledgeContextController.context","parameters":[{"description":"Search query (required, max 500 characters)","in":"query","name":"query","required":true,"schema":{"type":"string"}},{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Max results to return (default 5, max 20)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Weight for recency scoring, 0.0-1.0 (default 0.3). Higher values boost recently-updated articles.","in":"query","name":"recency_weight","required":false,"schema":{"type":"number"}},{"description":"Article status filter (published, draft, archived). Only effective for user/superadmin roles. Agent/orchestrator roles are forced to published.","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Agent-memory scope: comma-separated memory_types (OR) — observation|finding|summary|decision|question|task. Filters via metadata @>.","in":"query","name":"memory_types","required":false,"schema":{"type":"string"}},{"description":"Agent-memory scope: comma-separated agent_ids (OR). Filters via metadata @>.","in":"query","name":"agents","required":false,"schema":{"type":"string"}},{"description":"Agent-memory scope: exact conversation_id. Filters via metadata @>.","in":"query","name":"conversation_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Articles with full body, scores, and linked references","type":"array"},"meta":{"properties":{"fallback":{"description":"true when the underlying combined search degraded to keyword-only because embedding generation failed. Present only when it degraded.","type":"boolean"},"fallback_reason":{"description":"Present only alongside `fallback: true` (#297): a stable, non-sensitive tag naming WHY semantic ranking was unavailable (never leaks an api key or provider body). One of `no_embedding_key`, `embedding_circuit_open`, `embedding_timeout`, `embedding_request_failed`, `embedding_crash`, `embedding_error`, or `embedding_provider_error_<status>`.","type":"string"},"limit":{"type":"integer"},"recency_weight":{"type":"number"},"total_count":{"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Context results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Deep-read knowledge context","tags":["Knowledge Wiki"]}},"/api/v1/stories/{id}":{"delete":{"callbacks":{},"description":"Deletes a story. Requires user+ role.","operationId":"LoopctlWeb.StoryController.delete","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Delete story","tags":["Stories"]},"get":{"callbacks":{},"description":"Returns a single story with dependencies and artifacts.","operationId":"LoopctlWeb.StoryController.show","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryResponse"}}},"description":"Story detail"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get story","tags":["Stories"]},"patch":{"callbacks":{},"description":"Updates story metadata fields. Cannot update agent_status or verified_status.","operationId":"LoopctlWeb.StoryController.update","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryResponse"}}},"description":"Updated story"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update story","tags":["Stories"]}},"/api/v1/audit":{"get":{"callbacks":{},"description":"Returns paginated audit log entries for the authenticated tenant.","operationId":"LoopctlWeb.AuditController.index","parameters":[{"description":"Filter by entity type","in":"query","name":"entity_type","required":false,"schema":{"type":"string"}},{"description":"Filter by entity ID","in":"query","name":"entity_id","required":false,"schema":{"type":"string"}},{"description":"Filter by action","in":"query","name":"action","required":false,"schema":{"type":"string"}},{"description":"Filter by actor type","in":"query","name":"actor_type","required":false,"schema":{"type":"string"}},{"description":"Filter by actor ID","in":"query","name":"actor_id","required":false,"schema":{"type":"string"}},{"description":"Filter by project","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"ISO8601 start time","in":"query","name":"from","required":false,"schema":{"type":"string"}},{"description":"ISO8601 end time","in":"query","name":"to","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Audit log"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List audit log entries","tags":["Audit"]}},"/health/ready":{"get":{"callbacks":{},"description":"Deploy-time smoke gate distinct from the /health liveness probe. Fails when scale alerts are enabled but no webhook URL is configured, or when the Oban `:executing`-orphan backlog exceeds its configured threshold, in addition to the database/oban checks. Not wired into Fly's continuous load-balancer check.","operationId":"LoopctlWeb.HealthController.ready","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Ready"},"503":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Not ready"}},"security":[],"summary":"Readiness check (US-32.4, US-34.2)","tags":["Health"]}},"/api/v1/knowledge/analytics/projects/{id}/usage":{"get":{"callbacks":{},"description":"Returns total reads, unique articles, unique callers, access type breakdown, top articles, and a zero-filled daily read-count series for a single project. Cross-tenant or missing projects return 404. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeAnalyticsController.project_usage","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Look back this many days (default 7, min 1, max 365)","in":"query","name":"since_days","required":false,"schema":{"type":"integer"}},{"description":"Max top articles to return (default 20, max 100)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Project usage"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Per-project wiki usage rollup","tags":["Knowledge Analytics"]}},"/api/v1/knowledge/analytics/agents/{agent_id}":{"get":{"callbacks":{},"description":"Returns the reads, top articles, and access type breakdown for a single api_key OR logical agent. The path parameter is resolved against `api_keys.id` first, then `agents.id`. The response envelope includes `resolved_as: \"api_key\" | \"agent\"` so callers can tell which branch ran. Cross-tenant or missing ids return 404. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeAnalyticsController.agent_usage","parameters":[{"description":"API key UUID or agent UUID","in":"path","name":"agent_id","required":true,"schema":{"type":"string"}},{"description":"Max top articles to return (default 20, max 100)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Look back this many days (default 7, min 1, max 365)","in":"query","name":"since_days","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Agent usage"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Per-agent knowledge usage","tags":["Knowledge Analytics"]}},"/api/v1/projects/{project_id}/epics":{"get":{"callbacks":{},"description":"Lists epics for a project with pagination and phase filtering.","operationId":"LoopctlWeb.EpicController.index","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}},{"description":"Filter by phase","in":"query","name":"phase","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/EpicResponse"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Epic list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List epics","tags":["Epics"]},"post":{"callbacks":{},"description":"Creates a new epic within a project. Requires orchestrator+ role.","operationId":"LoopctlWeb.EpicController.create","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"description":{"nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"number":{"type":"integer"},"phase":{"nullable":true,"type":"string"},"position":{"type":"integer"},"title":{"type":"string"}},"required":["number","title"],"type":"object"}}},"description":"Epic params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EpicResponse"}}},"description":"Epic created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Project not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create epic","tags":["Epics"]}},"/api/v1/skills":{"get":{"callbacks":{},"description":"Lists skills with pagination and filtering.","operationId":"LoopctlWeb.SkillController.index","parameters":[{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}},{"description":"Filter by project","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Filter by name pattern","in":"query","name":"name","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/SkillResponse"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Skill list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List skills","tags":["Skills"]},"post":{"callbacks":{},"description":"Creates a skill with v1. Requires user role.","operationId":"LoopctlWeb.SkillController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"description":{"nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"name":{"type":"string"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"prompt_text":{"type":"string"}},"required":["name","prompt_text"],"type":"object"}}},"description":"Skill params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}},"description":"Skill created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create skill","tags":["Skills"]}},"/api/v1/stories/{id}/reject":{"post":{"callbacks":{},"description":"Orchestrator rejects a story with reason. Creates verification_result with result=fail.","operationId":"LoopctlWeb.StoryVerificationController.reject","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectRequest"}}},"description":"Rejection params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story rejected"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Reason required"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Reject story","tags":["Progress"]}},"/api/v1/knowledge/bulk-delete":{"post":{"callbacks":{},"description":"SET-BASED bulk cleanup. Provide **exactly one** selector (supplying more than one is a 400): `article_ids` (explicit list), `source_type` + `source_id` (every active article from that source), or `tag` + `confirm: true` (every active article carrying the tag — high blast radius, so `confirm: true` is required). All selectors are bounded to 5000 active matches (over that → 400). Tenant-scoped: foreign ids never match.\n\n**Default (soft) path** — archives the matched active set in ONE `update_all` + one audit event. Idempotent (re-archiving is a no-op). Returns `{data: {affected: N}, meta: {op: \"archive\", set_based: true, affected: N}}`.\n\n**`dry_run: true`** — previews `{would_affect: N}` and mutates nothing. For the irreversible delete path it also returns `meta.token` (a single-use, TTL-bounded frozen-set token) when N is within the bound, or `meta.oversized: true` + `meta.confirm_hash` for the re-confirm-on-drift path over the bound.\n\n**`hard: true`** — irreversible HARD delete (FK-correct: article_links pre-deleted both directions, access-events cascade). Requires a `token` from a prior dry-run, OR (for an oversized selector) the original selector plus the dry-run `confirm_hash` (refused on drift). Role: user+ (all destructive ops).","operationId":"LoopctlWeb.ArticleWorkflowController.bulk_delete","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"article_ids":{"items":{"format":"uuid","type":"string"},"type":"array"},"confirm":{"description":"Required (true) when deleting by tag.","type":"boolean"},"confirm_hash":{"description":"Echoed from an oversized dry_run; re-confirm-on-drift for hard delete.","type":"string"},"dry_run":{"description":"Preview only; mutates nothing. Mints a delete token for the hard path.","type":"boolean"},"hard":{"description":"Irreversible HARD delete (requires a token or confirm_hash).","type":"boolean"},"source_id":{"format":"uuid","type":"string"},"source_type":{"type":"string"},"tag":{"type":"string"},"token":{"description":"Frozen-set token from a prior dry_run; required for hard delete.","format":"uuid","type":"string"}},"type":"object"}}},"description":"Bulk delete selector","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"type":"object"}},"type":"object"}}},"description":"Bulk delete result (partial success; see meta.results / meta.counts)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request (no/ambiguous selector, tag without confirm, empty match, or over cap)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Bulk archive / hard-delete articles (set-based, US-27.12)","tags":["Knowledge Wiki"]}},"/api/v1/analytics/epics":{"get":{"callbacks":{},"description":"Returns per-epic cost breakdown including budget utilization and model breakdown. Filterable by project_id.","operationId":"LoopctlWeb.AnalyticsController.epics","parameters":[{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/TokenAnalyticsEpic"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Epic metrics"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Per-epic cost breakdown","tags":["Token Efficiency"]}},"/api/v1/token-usage":{"post":{"callbacks":{},"description":"Creates a standalone token usage report for a story without triggering a status transition.","operationId":"LoopctlWeb.TokenUsageController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"cost_millicents":{"minimum":0,"type":"integer"},"input_tokens":{"minimum":0,"type":"integer"},"metadata":{"additionalProperties":true,"type":"object"},"model_name":{"minLength":1,"type":"string"},"output_tokens":{"minimum":0,"type":"integer"},"phase":{"enum":["planning","implementing","reviewing","other"],"type":"string"},"session_id":{"nullable":true,"type":"string"},"skill_version_id":{"format":"uuid","nullable":true,"type":"string"},"story_id":{"format":"uuid","type":"string"}},"required":["story_id","input_tokens","output_tokens","model_name","cost_millicents"],"type":"object"}}},"description":"Token usage params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"token_usage_report":{"$ref":"#/components/schemas/TokenUsageReport"}},"type":"object"}}},"description":"Report created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Story not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create token usage report","tags":["Token Efficiency"]}},"/api/v1/stories/ready":{"get":{"callbacks":{},"description":"Returns stories ready to be assigned (pending, all deps verified).","operationId":"LoopctlWeb.DependencyGraphController.ready","parameters":[{"description":"Filter by project","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by epic","in":"query","name":"epic_id","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/StoryResponse"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Ready stories"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List ready stories","tags":["Dependencies"]}},"/api/v1/changes":{"get":{"callbacks":{},"description":"Cursor-based change feed for orchestrators. Returns audit log entries since a given timestamp.","operationId":"LoopctlWeb.ChangeController.index","parameters":[{"description":"ISO8601 timestamp. Required UNLESS a `cursor` is supplied. Used for the FIRST page (`inserted_at > since`); follow `next_cursor` thereafter.","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"Opaque KEYSET cursor (US-27.9b) — the drift-free continuation token. Follow `meta.next_cursor`/`next_cursor` verbatim. Unlike `since` (a timestamp, which can skip or duplicate rows that share a microsecond under bulk writes), the cursor seeks the stable `(inserted_at, id)` tuple and never drifts across ties. Takes precedence over `since` when both are given. Integrity-protected and tenant-bound; a tampered/forged cursor is rejected with 400.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Filter by project","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by entity type","in":"query","name":"entity_type","required":false,"schema":{"type":"string"}},{"description":"Filter by action","in":"query","name":"action","required":false,"schema":{"type":"string"}},{"description":"Max results","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"has_more":{"type":"boolean"},"next_cursor":{"description":"Drift-free keyset continuation token (US-27.9b); null when exhausted.","nullable":true,"type":"string"},"next_since":{"description":"Back-compat timestamp token (NOT tie-safe under bulk writes). New callers should follow `next_cursor`.","format":"date-time","nullable":true,"type":"string"}},"type":"object"}}},"description":"Change feed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Poll change feed","tags":["Audit"]}},"/api/v1/entities/{id}":{"delete":{"callbacks":{},"description":"Deletes a definition by id. Requires >= user role.","operationId":"LoopctlWeb.ContextRetrieverController.delete","parameters":[{"description":"Entity definition UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDefinitionResponse"}}},"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"}},"summary":"Delete an entity definition","tags":["Context Retriever"]},"get":{"callbacks":{},"description":"Fetches one of the calling tenant's entity definitions by id.","operationId":"LoopctlWeb.ContextRetrieverController.show","parameters":[{"description":"Entity definition UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDefinitionResponse"}}},"description":"Definition"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"}},"summary":"Fetch an entity definition","tags":["Context Retriever"]},"patch":{"callbacks":{},"description":"Updates a definition by id, re-validating against the SERVER column allowlist (a PATCH can never relax it). Requires >= user role. Omitted top-level fields keep their current value.","operationId":"LoopctlWeb.ContextRetrieverController.update","parameters":[{"description":"Entity definition UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDefinitionRequest"}}},"description":"Entity params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityDefinitionResponse"}}},"description":"Updated"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"}},"summary":"Update an entity definition","tags":["Context Retriever"]}},"/api/v1/story_dependencies/{id}":{"delete":{"callbacks":{},"description":"Removes a story dependency edge.","operationId":"LoopctlWeb.StoryDependencyController.delete","parameters":[{"description":"Dependency UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Delete story dependency","tags":["Dependencies"]}},"/api/v1/stories/{id}/review-complete":{"post":{"callbacks":{},"description":"Records that the review pipeline completed for a story. Must be called AFTER the story is in reported_done status and BEFORE verify. Creates a review_record that verify uses as proof of independent review.","operationId":"LoopctlWeb.ReviewRecordController.create","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"completed_at":{"description":"When the review completed (defaults to now). Must be after reported_done_at.","example":"2026-03-30T01:44:41Z","format":"date-time","type":"string"},"disproved_count":{"description":"Number of findings disproved as false positives. fixes_count + disproved_count must equal findings_count.","example":0,"type":"integer"},"findings_count":{"description":"Number of findings identified","example":5,"type":"integer"},"fixes_count":{"description":"Number of findings that were fixed","example":5,"type":"integer"},"review_type":{"description":"Type of review conducted","example":"enhanced","type":"string"},"summary":{"description":"Summary of review findings and outcome","example":"Enhanced review completed. 5 findings, all fixed.","type":"string"}},"required":["review_type"],"type":"object"}}},"description":"Review completion params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReviewRecordResponse"}}},"description":"Review recorded"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Story not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Story not in reported_done status"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Record review completion","tags":["Progress"]}},"/api/v1/tenants/{id}/authenticators/revoke-challenge":{"post":{"callbacks":{},"description":"Issues a fresh-assertion challenge (purpose revoke_authenticator) for an existing authenticator. Requires user role and tenant ownership.","operationId":"LoopctlWeb.TenantAuthenticatorController.revoke_challenge","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReauthChallengeResponse"}}},"description":"Reauth challenge"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"No enrolled authenticators"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Rate limited"}},"summary":"Issue an authenticator-revocation reauth challenge","tags":["Tenants"]}},"/api/v1/analytics/trends":{"get":{"callbacks":{},"description":"Returns cost trend data grouped by day or week. Filterable by project_id and date range.","operationId":"LoopctlWeb.AnalyticsController.trends","parameters":[{"description":"Grouping: 'daily' (default) or 'weekly'","in":"query","name":"granularity","required":false,"schema":{"type":"string"}},{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Start date (YYYY-MM-DD)","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"End date (YYYY-MM-DD)","in":"query","name":"until","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/TokenAnalyticsTrend"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Trend metrics"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Daily/weekly cost trend","tags":["Token Efficiency"]}},"/api/v1/stories/bulk/claim":{"post":{"callbacks":{},"description":"Agent claims multiple pending stories. Partial-success semantics.","operationId":"LoopctlWeb.BulkOperationsController.claim","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkClaimRequest"}}},"description":"Claim params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResultResponse"}}},"description":"Results"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid input"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Bulk claim stories","tags":["Progress"]}},"/api/v1/projects/{project_id}/knowledge/lint":{"get":{"callbacks":{},"description":"Analyzes published articles and returns a structured report of potential issues including stale articles, orphaned articles, contradiction clusters, coverage gaps, and broken source references. Read-only operation. When called via GET /projects/:project_id/knowledge/lint, scopes analysis to project-specific and tenant-wide articles. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeLintController.lint (2)","parameters":[{"description":"Project UUID (optional, for project-scoped lint)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Number of days without update before an article is considered stale (default 90)","in":"query","name":"stale_days","required":false,"schema":{"type":"integer"}},{"description":"Minimum published articles per category to avoid a coverage gap (default 3)","in":"query","name":"min_coverage","required":false,"schema":{"type":"integer"}},{"description":"Maximum items returned per issue category (default 50, max 500). Totals before capping are returned in summary.total_per_category.","in":"query","name":"max_per_category","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Lint findings grouped by issue type","type":"object"},"summary":{"properties":{"generated_at":{"type":"string"},"issues_by_severity":{"type":"object"},"total_articles":{"type":"integer"},"total_issues":{"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Knowledge lint report"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Knowledge lint report","tags":["Knowledge Wiki"]}},"/api/v1/article_links":{"post":{"callbacks":{},"description":"Creates a directed link between two articles in the same tenant. When relationship_type is 'supersedes', the target article's status is set to 'superseded' (retired), so that destructive relationship_type requires role: user+. Non-destructive types (relates_to, derived_from, contradicts) are agent+.","operationId":"LoopctlWeb.ArticleLinkController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"metadata":{"additionalProperties":true,"type":"object"},"relationship_type":{"enum":["relates_to","derived_from","contradicts","supersedes"],"type":"string"},"source_article_id":{"format":"uuid","type":"string"},"target_article_id":{"format":"uuid","type":"string"}},"required":["source_article_id","target_article_id","relationship_type"],"type":"object"}}},"description":"ArticleLink params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Link created"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden (a 'supersedes' link requires role: user+)"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error (incl. a non-public/unknown relationship_type)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create article link","tags":["Knowledge Wiki"]}},"/api/v1/egress/trusted-endpoints/{host}":{"delete":{"callbacks":{},"description":"Role :user ONLY. Invalidates the pin cache immediately.","operationId":"LoopctlWeb.EgressController.revoke_trusted","parameters":[{"description":"Declared host","in":"path","name":"host","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Endpoint revoked"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Insufficient role"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"}},"summary":"Revoke a tenant-declared trusted endpoint","tags":["Egress"]}},"/api/v1/admin/stats":{"get":{"callbacks":{},"description":"Returns system-wide aggregate statistics. Requires superadmin.","operationId":"LoopctlWeb.AdminStatsController.show","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"System stats"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"System-wide stats (admin)","tags":["Admin"]}},"/api/v1/projects/{project_id}/ui-tests/{id}/complete":{"post":{"callbacks":{},"description":"Marks the run as passed or failed and records a summary.","operationId":"LoopctlWeb.UiTestController.complete","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"UI test run UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompleteUiTestRequest"}}},"description":"Complete run params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Run completed"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Run not in progress or validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Complete a UI test run","tags":["UI Tests"]}},"/api/v1/stories/{story_id}/token-usage":{"get":{"callbacks":{},"description":"Returns all token usage reports for a story, ordered by inserted_at descending. Includes totals.","operationId":"LoopctlWeb.TokenUsageController.index","parameters":[{"description":"Story UUID","in":"path","name":"story_id","required":true,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/TokenUsageReport"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"},"totals":{"description":"Aggregated totals for all reports in this story","properties":{"report_count":{"type":"integer"},"total_cost_dollars":{"type":"string"},"total_cost_millicents":{"type":"integer"},"total_input_tokens":{"type":"integer"},"total_output_tokens":{"type":"integer"},"total_tokens":{"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Token usage list"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Story not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List token usage reports for a story","tags":["Token Efficiency"]}},"/api/v1/knowledge/okf/import":{"post":{"callbacks":{},"description":"Imports an OKF v0.1 bundle supplied as a `files` map (path => contents). Reserved files (index.md/log.md) are skipped; each concept is created, or (with merge=true, the default) updated in place when it matches an existing article by loopctl_id or title. Per OKF's permissive-consumer rule the import never aborts on unknown types/keys — per-file outcomes are reported. Role: user+.","operationId":"LoopctlWeb.OKFController.import","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"dry_run":{"description":"default false","type":"boolean"},"files":{"description":"Map of bundle-relative path => file contents","type":"object"},"merge":{"description":"default true","type":"boolean"},"project_id":{"type":"string"}},"required":["files"],"type":"object"}}},"description":"OKF import request","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Report with created/updated/skipped/links_created counts, errors, and conformance","type":"object"}},"type":"object"}}},"description":"Import report"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Import an OKF bundle","tags":["Knowledge Wiki"]}},"/api/v1/projects/resolve":{"get":{"callbacks":{},"description":"Resolves a project from any of slug, repo_url, or name (precedence: slug, repo_url, name). Requires agent+ role.","operationId":"LoopctlWeb.ProjectController.resolve","parameters":[{"description":"Exact project slug","in":"query","name":"slug","required":false,"schema":{"type":"string"}},{"description":"Repository URL (ssh, https, or bare owner/repo)","in":"query","name":"repo_url","required":false,"schema":{"type":"string"}},{"description":"Project name (case-insensitive)","in":"query","name":"name","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Resolved project"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"No identifier supplied"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Resolve project","tags":["Projects"]}},"/api/v1/tenants/{id}/audit_public_key":{"get":{"callbacks":{},"description":"Returns the tenant's ed25519 audit signing public key as PEM (default) or JWK (Accept: application/jwk+json). Public — no authentication required.","operationId":"LoopctlWeb.TenantAuditKeyController.show","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/x-pem-file":{"schema":{"type":"string"}}},"description":"Public key (PEM or JWK)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"}},"security":[],"summary":"Get tenant audit public key","tags":["Tenants"]}},"/api/v1/projects/{project_id}/stories":{"post":{"callbacks":{},"description":"Creates a new story by looking up the epic by its human-readable `number` instead of UUID. Friendlier for agents who know the epic number (e.g. 72) but not the UUID. Requires orchestrator+ role.","operationId":"LoopctlWeb.StoryController.create_in_project","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"acceptance_criteria":{"items":{"type":"object"},"nullable":true,"type":"array"},"description":{"nullable":true,"type":"string"},"epic_number":{"type":"integer"},"estimated_hours":{"nullable":true,"type":"number"},"metadata":{"additionalProperties":true,"type":"object"},"number":{"type":"string"},"title":{"type":"string"}},"required":["epic_number","number","title"],"type":"object"}}},"description":"Story params with epic_number","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryResponse"}}},"description":"Story created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Project not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error or epic_number not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create story (by epic number)","tags":["Stories"]}},"/api/v1/epics/{epic_id}/stories":{"get":{"callbacks":{},"description":"Lists stories for an epic with pagination and status filtering.","operationId":"LoopctlWeb.StoryController.index","parameters":[{"description":"Epic UUID","in":"path","name":"epic_id","required":true,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}},{"description":"Filter by agent status","in":"query","name":"agent_status","required":false,"schema":{"type":"string"}},{"description":"Filter by verified status","in":"query","name":"verified_status","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/StoryResponse"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Story list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List stories","tags":["Stories"]},"post":{"callbacks":{},"description":"Creates a new story within an epic. Requires orchestrator+ role.","operationId":"LoopctlWeb.StoryController.create","parameters":[{"description":"Epic UUID","in":"path","name":"epic_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"acceptance_criteria":{"items":{"type":"object"},"nullable":true,"type":"array"},"description":{"nullable":true,"type":"string"},"estimated_hours":{"nullable":true,"type":"number"},"metadata":{"additionalProperties":true,"type":"object"},"number":{"type":"string"},"title":{"type":"string"}},"required":["number","title"],"type":"object"}}},"description":"Story params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryResponse"}}},"description":"Story created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Epic not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create story","tags":["Stories"]}},"/api/v1/knowledge/search":{"get":{"callbacks":{},"description":"Unified search endpoint supporting keyword, semantic, and combined modes. Returns article metadata with scores and snippets (max 300 chars). No full body is returned. Combined mode is the default and falls back to keyword-only if embedding generation fails. `q` is optional when `tags` and/or `category` are supplied: in that **list mode** the endpoint returns the complete filtered set (no relevance ranking, score 0.0, no snippet) ordered by recency, fully reachable via `offset`/`limit` pagination over `meta.total_count`. **`meta.total_count` is mode-dependent** — `meta.total_count_scope` says exactly what it counts: `keyword_matches` (articles matching the stop-word-filtered Postgres tsquery — a pure stop-word query like 'the' matches almost nothing), `ranked_corpus` (semantic ranks all EMBEDDED published articles, so the count is the size of that embedded set — not a match count, and <= the total published count), `merged_candidates` (combined mode: the deduped UNION of a keyword and a semantic sub-search, each capped at 100, so up to ~200), or `filtered_set` (list mode: the complete filtered set). Do NOT use a relevance-mode `total_count` to size the corpus — use list mode or `GET /knowledge/stats`. Role: agent+.","operationId":"LoopctlWeb.KnowledgeSearchController.search","parameters":[{"description":"Search query (max 500 characters). Optional when tags/category are supplied.","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"description":"Search mode: keyword, semantic, or combined (default: combined)","in":"query","name":"mode","required":false,"schema":{"type":"string"}},{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by category","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Comma-separated tags to filter by (match mode set by `match`)","in":"query","name":"tags","required":false,"schema":{"type":"string"}},{"description":"Tag match mode: any (default, OR) or all (AND — carries every listed tag)","in":"query","name":"match","required":false,"schema":{"type":"string"}},{"description":"Max results to return (default 10). The cap is mode-dependent; a limit above it is clamped (never rejected) and the effective value is returned in `meta.limit`. **List mode** (no `q`, just `tags`/`category`) is exhaustive enumeration: max 1000, paginate the complete filtered set via `offset`. **Relevance modes** (keyword / semantic / combined) return a ranked top-N: max 100 (drop `q` for full enumeration).","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Results to skip for pagination (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Opaque KEYSET cursor for drift-free list enumeration (list mode only). To use cursor pagination, pass an empty string (`cursor=`) on the FIRST request to opt into the keyset path (which orders by `inserted_at ASC, id ASC`); then follow `meta.next_cursor` verbatim on subsequent requests. Omitting the `cursor` parameter entirely uses the legacy offset path (orders by `updated_at DESC`), which does not emit `next_cursor`. Do not mix the two paths mid-enumeration, as the sort order differs. The cursor is integrity-protected and tenant-bound — a tampered/forged cursor is rejected with 400. Not valid with `q` (relevance modes return a ranked top-N, not a walk).","in":"query","name":"cursor","required":false,"schema":{"type":"string"}},{"description":"Opt into article `body` on each keyset (`cursor`) list row (default false, **keyset-path-only** — not supported on offset paths). Body-less is the default to keep payloads small and avoid large chunked responses. When `include_body=true`, the response is trimmed by a 5MB serialized-body budget (same as offset full-content pages), so `count` may be less than `limit` if bodies are large. Honored ONLY for an effective `limit <= 25`; a request with `include_body=true` AND a requested `limit > 25` is rejected with 400 (never a silent oversized response). Only `true` enables it; any other value is false.","in":"query","name":"include_body","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Matching articles with scores and snippets","type":"array"},"meta":{"description":"Offset/relevance modes return total_count/offset; the keyset list path (`cursor`) instead returns the self-describing cursor contract: next_cursor, has_more, limit, count, include_body.","properties":{"byte_truncated":{"description":"Keyset path (include_body only): true when the page was shortened by the serialized-body byte budget. next_cursor is then recomputed from the last kept row, so following it returns the dropped rows (no gap).","type":"boolean"},"count":{"description":"Keyset path: number of rows in THIS page (length of data)","type":"integer"},"fallback":{"description":"Relevance modes (combined / semantic): true when embedding generation failed and the request silently degraded to keyword_only. Present only when it degraded.","type":"boolean"},"fallback_reason":{"description":"Present only alongside `fallback: true` (#297): a stable, non-sensitive tag naming WHY semantic ranking was unavailable (never leaks an api key or provider body). One of `no_embedding_key`, `embedding_circuit_open`, `embedding_timeout`, `embedding_request_failed`, `embedding_crash`, `embedding_error`, or `embedding_provider_error_<status>` (e.g. `embedding_provider_error_401`, carrying only the HTTP status).","type":"string"},"has_more":{"description":"Keyset path: whether another page exists (exactly next_cursor != null), derived from the limit+1 peek, never a COUNT","type":"boolean"},"include_body":{"description":"Keyset path: whether each row carries the article body (honored only for limit <= 25; see the include_body parameter)","type":"boolean"},"limit":{"description":"Effective per-page limit that actually ran","type":"integer"},"next_cursor":{"description":"Keyset path: opaque cursor for the next page; null when the walk is exhausted (the only exhaustion signal — there is no total_count)","nullable":true,"type":"string"},"offset":{"description":"Offset path only; absent on the keyset (`cursor`) path","type":"integer"},"pool_capped":{"description":"Relevance modes (semantic / combined): true when the ranked+filtered results may be INCOMPLETE — either the corpus exceeds the relevance pool cap, or a selective filter starved the pool below the cap. `false` means this query's results are complete; `total_count` can exceed what relevance pagination reaches, so on `pool_capped: true` switch to list mode (`cursor`) for full enumeration.","type":"boolean"},"remediation":{"description":"Present ONLY when `fallback_reason == \"no_embedding_key\"`: a machine-readable, secret-free next-step so an agent can enable semantic ranking WITHOUT a human. Names the `set_llm_config` MCP tool (`mcp_tool`), the REST endpoint (`api`), the missing credential (`missing: [\"embedding_api_key\"]`), a copy-paste `example`, and the onboarding `docs`. Absent for transient/provider fallbacks (a key IS configured there).","type":"object"},"search_mode":{"description":"The mode that actually ran (keyword_only = combined degraded to keyword; list_keyset = the cursor enumeration path)","enum":["keyword","list","list_keyset","semantic_only","combined","keyword_only"],"type":"string"},"semantic_result_count":{"description":"Combined mode only (#297): rows the semantic half contributed. `0` with no `fallback` means the embedding SUCCEEDED but ranking returned nothing (a recall problem) — distinct from a keyword_only fallback.","type":"integer"},"total_count":{"type":"integer"},"total_count_scope":{"description":"What total_count counts for this mode","enum":["keyword_matches","ranked_corpus","merged_candidates","filtered_set"],"type":"string"}},"type":"object"}},"type":"object"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"503":{"content":{"application/json":{"schema":{"properties":{"error":{"properties":{"message":{"type":"string"},"status":{"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Service unavailable"}},"summary":"Search knowledge articles","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/pipeline":{"get":{"callbacks":{},"description":"Returns metrics about the self-learning knowledge extraction pipeline including pending extractions, recent drafts, publish rate, extraction errors, and the auto_extract_enabled setting. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgePipelineController.status","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"properties":{"auto_extract_enabled":{"description":"Whether automatic knowledge extraction is enabled","type":"boolean"},"extraction_errors":{"properties":{"count":{"type":"integer"},"recent":{"type":"array"}},"type":"object"},"pending_extractions":{"description":"Count of pending ReviewKnowledgeWorker jobs","type":"integer"},"publish_rate":{"description":"Ratio of published to total review_finding articles (0.0-1.0)","type":"number"},"recent_drafts":{"description":"20 most recent draft articles from review findings","type":"array"}},"type":"object"}},"type":"object"}}},"description":"Pipeline status"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Knowledge pipeline status","tags":["Knowledge Wiki"]}},"/api/v1/memory/recall":{"post":{"callbacks":{},"description":"Recalls the caller's own long-term memories most similar to `query` (cosine over an HNSW index), scoped to the key's `(tenant_id, subject_id)`. An optional `project_id` (UUID) partitions the result: absent/blank returns GLOBAL memories only (the rows whose `project_id` is NULL — NOT a union across all your projects), while a present `project_id` returns the merged `global ∪ that-project` set; another project's memories are excluded. A malformed `project_id` is a 422 (`invalid_project_id`). NOTE the deliberate asymmetry with `POST /memory` (create): recall does NOT tenant-validate a well-formed `project_id`, because it is a partition key, not the isolation boundary. A well-formed `project_id` that is a typo, stale, or owned by another tenant is treated as an empty partition and returns your GLOBAL rows only with NO error (never any other tenant's/subject's rows — the `(tenant_id, subject_id)` predicate still bounds every result), whereas create 422s the same value. The query is supplied in the request BODY. When embedding generation is unavailable the response degrades to a recent-first text match with `meta.fallback: true` and a stable `meta.reason` (score is null on that path) — never a silent empty result. No silent hard cap: `limit` is clamped to the vector-search max and `meta.underfilled` flags a short page (a small live scope, or a cross-subject/cross-project pool under-fill).","operationId":"LoopctlWeb.MemoryController.recall","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryRecallRequest"}}},"description":"Recall params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryRecallResponse"}}},"description":"Recall results"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Subject unresolvable, non-string query, or invalid project_id"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Tenant custody halted"}},"summary":"Recall (semantic search)","tags":["Agent Memory"]}},"/api/v1/projects/{id}/epic_dependencies":{"get":{"callbacks":{},"description":"Lists all epic dependency edges for a project.","operationId":"LoopctlWeb.EpicDependencyController.index","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Dependencies"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List epic dependencies","tags":["Dependencies"]}},"/api/v1/knowledge/progressive_index":{"get":{"callbacks":{},"description":"Returns a bounded, topic-scoped list of compact stubs (id/title/category/summary — never bodies), capped at top-K, with curated sources preferred and hub-linked neighbors enriched in. Use it to cheaply survey what's relevant to a topic, then drill into only the article(s) you need via GET /knowledge/progressive/:id. `meta.truncated` is true when the candidate pool exceeded top-K. Role: agent+.","operationId":"LoopctlWeb.KnowledgeProgressiveController.index","parameters":[{"description":"The topic to index (max 500 characters). Required.","in":"query","name":"topic","required":true,"schema":{"type":"string"}},{"description":"Optional: filter by category.","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Optional: top-K override (clamped to the configured cap).","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Compact stubs: id, title, category, summary (no bodies).","type":"array"},"meta":{"properties":{"candidate_count":{"type":"integer"},"top_k":{"type":"integer"},"truncated":{"type":"boolean"}},"type":"object"}},"type":"object"}}},"description":"Progressive index stubs"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Progressive-disclosure index","tags":["Knowledge Wiki"]}},"/api/v1/stories/{id}/start-work":{"post":{"callbacks":{},"description":"Agent starts work on an assigned story.","operationId":"LoopctlWeb.StoryStatusController.start","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story started"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not assigned agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Start story","tags":["Progress"]}},"/api/v1/admin/tenants/{id}/activate":{"post":{"callbacks":{},"description":"Activates a tenant. Returns 422 if already active.","operationId":"LoopctlWeb.AdminTenantController.activate","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Tenant activated"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Already active"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Activate tenant (admin)","tags":["Admin"]}},"/api/v1/agents/{id}":{"get":{"callbacks":{},"description":"Returns agent detail. Requires orchestrator+ role.","operationId":"LoopctlWeb.AgentController.show","parameters":[{"description":"Agent UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}},"description":"Agent detail"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get agent","tags":["Agents"]}},"/api/v1/stories/{id}/report":{"post":{"callbacks":{},"description":"A DIFFERENT agent (reviewer) reports story as done. The implementing agent cannot call this (chain-of-custody). Optionally includes an artifact report and/or a token usage record.","operationId":"LoopctlWeb.StoryStatusController.report","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"artifact":{"description":"Optional artifact report to attach to this story","properties":{"artifact_type":{"type":"string"},"details":{"additionalProperties":true,"type":"object"},"exists":{"type":"boolean"},"path":{"type":"string"}},"type":"object"},"token_usage":{"description":"Optional token usage to report alongside the story completion. When provided, creates a token_usage_report record for this story.","properties":{"cost_millicents":{"description":"Cost in millicents (1/1000 of a cent)","minimum":0,"type":"integer"},"input_tokens":{"description":"Input tokens consumed","minimum":0,"type":"integer"},"model_name":{"description":"LLM model name","example":"claude-opus-4-5","minLength":1,"type":"string"},"output_tokens":{"description":"Output tokens consumed","minimum":0,"type":"integer"},"phase":{"description":"Work phase (default: other)","enum":["planning","implementing","reviewing","other"],"type":"string"},"session_id":{"description":"Optional session identifier","nullable":true,"type":"string"}},"type":"object"}},"type":"object"}}},"description":"Report params (optional artifact and token_usage)","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story reported done"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition or self-report blocked"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Report story done","tags":["Progress"]}},"/api/v1/admin/tenants/{id}/clear-halt/challenge":{"post":{"callbacks":{},"description":"Step 1 of the challenge-bound WebAuthn reauthentication ceremony for break-glass custody-halt recovery. Issues an authentication challenge bound to the target tenant's enrolled root authenticators, stores it server-side (single-use, short TTL) and returns the opaque `challenge_id`, the base64url challenge bytes, and the allowed credential ids. Requires superadmin.","operationId":"LoopctlWeb.AdminTenantController.clear_halt_challenge","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReauthChallengeResponse"}}},"description":"Reauth challenge"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"No enrolled authenticators"}},"summary":"Issue a break-glass clear-halt reauth challenge (admin)","tags":["Admin"]}},"/api/v1/retrieve/tools":{"get":{"callbacks":{},"description":"Returns the generated agent tool specs (ToolGenerator over the tenant's entity definitions) for the CALLING tenant only. Another tenant's entities never appear.","operationId":"LoopctlWeb.ContextRetrieverController.tools","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetrieveToolsResponse"}}},"description":"Tool specs"}},"summary":"List generated tool specs","tags":["Context Retriever"]}},"/api/v1/article_links/{id}":{"delete":{"callbacks":{},"description":"Deletes an article link. Role: user+.","operationId":"LoopctlWeb.ArticleLinkController.delete","parameters":[{"description":"ArticleLink UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"No content"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Delete article link","tags":["Knowledge Wiki"]}},"/api/v1/epics/{id}/progress":{"get":{"callbacks":{},"description":"Returns epic-level progress: story count by agent_status and verified_status.","operationId":"LoopctlWeb.EpicController.progress","parameters":[{"description":"Epic UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Epic progress"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get epic progress","tags":["Epics"]}},"/api/v1/stories/{id}/claim":{"post":{"callbacks":{},"description":"Agent claims a contracted story. Uses pessimistic locking.","operationId":"LoopctlWeb.StoryStatusController.claim","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story claimed"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition or dependencies not met"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Claim story","tags":["Progress"]}},"/api/v1/skills/{id}/versions/{version}":{"get":{"callbacks":{},"description":"Returns a specific version of a skill.","operationId":"LoopctlWeb.SkillController.get_version","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Version number","in":"path","name":"version","required":true,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillVersionResponse"}}},"description":"Version detail"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid version"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get skill version","tags":["Skills"]}},"/api/v1/articles/{id}/archive":{"post":{"callbacks":{},"description":"Transitions article to archived status (soft delete — reversible, audited). Valid from draft or published. Returns 422 if superseded. Role: agent+ (an agent may only archive an article it can see — another agent's private/owner memory 404s).","operationId":"LoopctlWeb.ArticleWorkflowController.archive","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Archived article"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Archive article","tags":["Knowledge Wiki"]}},"/api/v1/projects/{id}":{"delete":{"callbacks":{},"description":"Archives a project (soft delete). Requires user+ role.","operationId":"LoopctlWeb.ProjectController.delete","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Archived project"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Archive project","tags":["Projects"]},"get":{"callbacks":{},"description":"Returns project detail with epic and story counts.","operationId":"LoopctlWeb.ProjectController.show","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Project detail"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get project","tags":["Projects"]},"patch":{"callbacks":{},"description":"Updates a project. Slug cannot be changed. Requires user+ role.","operationId":"LoopctlWeb.ProjectController.update (2)","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreateRequest"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Updated project"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update project","tags":["Projects"]},"put":{"callbacks":{},"description":"Updates a project. Slug cannot be changed. Requires user+ role.","operationId":"LoopctlWeb.ProjectController.update","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreateRequest"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Updated project"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update project","tags":["Projects"]}},"/api/v1/analytics/models":{"get":{"callbacks":{},"description":"Returns per-model token usage, cost, and verification correlation metrics. Filterable by project_id and date range.","operationId":"LoopctlWeb.AnalyticsController.models","parameters":[{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Start date (YYYY-MM-DD)","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"End date (YYYY-MM-DD)","in":"query","name":"until","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/TokenAnalyticsModel"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Model metrics"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Model mix analysis","tags":["Token Efficiency"]}},"/api/v1/memory/{id}":{"delete":{"callbacks":{},"description":"Deletes a long-term memory by id within the caller's own subject scope. A foreign-subject, foreign-tenant, or unknown id returns 404 (no existence leak). Superadmin oversight: a superadmin key may delete ANY memory within its tenant.","operationId":"LoopctlWeb.MemoryController.delete","parameters":[{"description":"Memory UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryDeleteResponse"}}},"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Superadmin oversight delete without an impersonation target"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Forget (delete a memory)","tags":["Agent Memory"]}},"/api/v1/knowledge/drafts":{"get":{"callbacks":{},"description":"Lists draft articles ordered by inserted_at desc. Includes source_type and source_id for review queue visibility. Role: orchestrator+.","operationId":"LoopctlWeb.ArticleWorkflowController.drafts","parameters":[{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Max results per page (default 20, max 1000). A limit above the max is clamped to the maximum — never rejected — so pagination stays complete.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Records to skip","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"type":"object"}},"type":"object"}}},"description":"Drafts list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List draft articles","tags":["Knowledge Wiki"]}},"/api/v1/projects/{project_id}/ui-tests/{id}/findings":{"post":{"callbacks":{},"description":"Appends a structured finding to an in-progress run.","operationId":"LoopctlWeb.UiTestController.add_finding","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"UI test run UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UiTestFindingRequest"}}},"description":"Finding params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Run updated with finding"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Run not in progress"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Add a finding to a UI test run","tags":["UI Tests"]}},"/api/v1/knowledge/pairs":{"get":{"callbacks":{},"description":"Returns paginated pairs of articles whose embedding cosine distance is in the optimal-novelty band [`min_distance`, `max_distance`] (default 0.3–0.7) — the creative sweet spot. With `bridge_path=true`, only pairs also connected in the link graph (≤2 hops) are returned (that branch samples a smaller 500-article slice so its per-pair graph check stays within budget). Agent callers see only their own and `shared` articles. Each pair: `{a, b, distance}`. Samples up to 1000 embedded published visible articles (lowest-id slice; operator-tunable). `meta` carries `count` (items in this page) and `has_more` (a `limit+1` look-ahead) for pagination. NOTE: `meta.total_count` is DEPRECATED and always `null` on this endpoint — unlike sibling offset/limit endpoints, an EXACT total here requires a full O(candidates²) pass that dominated latency at scale (#202/#203), so it was removed; page via `has_more`. Role: agent+.","operationId":"LoopctlWeb.KnowledgeCreativityController.pairs","parameters":[{"description":"Lower cosine-distance bound (default 0.3)","in":"query","name":"min_distance","required":false,"schema":{"type":"number"}},{"description":"Upper cosine-distance bound (default 0.7)","in":"query","name":"max_distance","required":false,"schema":{"type":"number"}},{"description":"Require a ≤2-hop graph path (default false)","in":"query","name":"bridge_path","required":false,"schema":{"type":"boolean"}},{"description":"Max pairs (default 20, max 100)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Pairs to skip","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Distant pairs [a, b, distance]","type":"array"},"meta":{"properties":{"count":{"description":"Items in this page","type":"integer"},"has_more":{"description":"More pairs exist beyond this page (limit+1 look-ahead)","type":"boolean"},"total_count":{"deprecated":true,"description":"DEPRECATED — always null. An exact total pair count is an O(candidates²) cost (#202/#203); paginate via has_more instead.","nullable":true,"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Pairs"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Distant-but-bridgeable article pairs","tags":["Knowledge Wiki"]}},"/api/v1/retrieve/{entity}":{"post":{"callbacks":{},"description":"Executes a `filter` or `search` over the named entity via the US-30.3 Executor, which re-validates the field/operation against the tenant's definition + the SERVER allowlist and dual tenant-scopes the query. Rate-limited per tenant (429 over-limit, not executed). An unknown entity or non-allowlisted field is 4xx and never executed.","operationId":"LoopctlWeb.ContextRetrieverController.retrieve","parameters":[{"description":"Entity name","in":"path","name":"entity","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetrieveRequest"}}},"description":"Retrieve params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetrieveResponse"}}},"description":"Results + meta"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unknown entity"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Field not allowlisted / invalid operation"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Execute a filter/search over an entity","tags":["Context Retriever"]}},"/api/v1/knowledge/count":{"get":{"callbacks":{},"description":"Returns the count of articles matching the filters within the caller's visible set, without returning any rows. Agent callers see only their own and `shared` articles. Accepts the same filters as the article list (`category`, `status`, `tags`, `match`, `source_type`, `source_id`, `idempotency_key`, `project_id`). With `tags=a,b&match=all` it counts articles carrying BOTH tags; combine with `status=published` for \"how many published articles tagged both (that I can see)\". Role: agent+.","operationId":"LoopctlWeb.KnowledgeFacetsController.count","parameters":[{"description":"Filter by category","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Filter by tags (comma-separated)","in":"query","name":"tags","required":false,"schema":{"type":"string"}},{"description":"Tag match mode: any (default, OR) or all (AND)","in":"query","name":"match","required":false,"schema":{"type":"string"}},{"description":"Filter by source_type","in":"query","name":"source_type","required":false,"schema":{"type":"string"}},{"description":"Filter by source_id","in":"query","name":"source_id","required":false,"schema":{"type":"string"}},{"description":"Filter by idempotency_key","in":"query","name":"idempotency_key","required":false,"schema":{"type":"string"}},{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"count":{"type":"integer"}},"type":"object"}}},"description":"Count"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Count articles (no rows)","tags":["Knowledge Wiki"]}},"/api/v1/token-usage/{id}":{"delete":{"callbacks":{},"description":"Soft-deletes a token usage report by setting deleted_at. Report is excluded from all queries and analytics. Budget flags are reset if spend drops below threshold. Only users (not agents) may delete reports.","operationId":"LoopctlWeb.TokenUsageController.delete","parameters":[{"description":"Report UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"token_usage_report":{"$ref":"#/components/schemas/TokenUsageReport"}},"type":"object"}}},"description":"Report soft-deleted"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Report not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Soft-delete a token usage report","tags":["Token Efficiency"]}},"/api/v1/ingestion-anomalies":{"get":{"callbacks":{},"description":"Returns unresolved ingestion anomalies for the tenant — both capture_silence (a source_type that stopped producing articles) and high_reject_rate (writes attempted but rejected at high rate, which persist no article row). Filterable by source_type and anomaly_type. Archived anomalies are excluded by default; use ?include_archived=true to include them. A malformed ?resolved value or an unknown ?anomaly_type is rejected with 422. The response `meta.filters` echoes the effective filters, and `meta.warnings` flags a source_type filter that names a never-seen source (so an empty list is not mistaken for healthy).","operationId":"LoopctlWeb.IngestionAnomalyController.index","parameters":[{"description":"Filter by monitored article source_type (e.g. session_log)","in":"query","name":"source_type","required":false,"schema":{"type":"string"}},{"description":"Filter by anomaly type: capture_silence or high_reject_rate","in":"query","name":"anomaly_type","required":false,"schema":{"type":"string"}},{"description":"Include archived anomalies (default: false)","in":"query","name":"include_archived","required":false,"schema":{"type":"boolean"}},{"description":"Filter by resolved status: true = resolved only, false = unresolved only (default: false), all = both resolved and unresolved (complete timeline)","in":"query","name":"resolved","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"type":"object"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Anomaly list"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid 'resolved' or 'anomaly_type' filter value"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List ingestion anomalies (capture-silence + high-reject-rate)","tags":["Knowledge Wiki"]}},"/api/v1/projects/{id}/progress":{"get":{"callbacks":{},"description":"Returns progress summary for a project.","operationId":"LoopctlWeb.ProjectController.progress","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Progress summary"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get project progress","tags":["Projects"]}},"/api/v1/orchestrator/state/{project_id}":{"get":{"callbacks":{},"description":"Retrieves orchestrator state. Defaults to state_key='main'.","operationId":"LoopctlWeb.OrchestratorStateController.show","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}},{"description":"State key (default: main)","in":"query","name":"state_key","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"State"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get orchestrator state","tags":["Orchestrator"]},"put":{"callbacks":{},"description":"Saves (upserts) orchestrator state with optimistic locking.","operationId":"LoopctlWeb.OrchestratorStateController.save","parameters":[{"description":"Project UUID","in":"path","name":"project_id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrchestratorStateRequest"}}},"description":"State params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"State saved"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Project not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Version conflict"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Save orchestrator state","tags":["Orchestrator"]}},"/api/v1/analytics/agents/{id}/model-profile":{"get":{"callbacks":{},"description":"Returns a specific agent's model usage profile across phases. Includes model_count and is_model_blender (true if agent uses more than one model). Filterable by project_id and date range.","operationId":"LoopctlWeb.AnalyticsController.agent_model_profile","parameters":[{"description":"Agent UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Start date (YYYY-MM-DD)","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"End date (YYYY-MM-DD)","in":"query","name":"until","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"description":"Agent's model usage profile across phases. Includes model_count and is_model_blender flag.","properties":{"data":{"properties":{"agent_id":{"format":"uuid","type":"string"},"agent_name":{"type":"string"},"is_model_blender":{"description":"True if agent uses more than one model","type":"boolean"},"model_count":{"description":"Number of distinct models used","type":"integer"},"models":{"description":"Per-model usage breakdown across phases","items":{"additionalProperties":true,"type":"object"},"type":"array"}},"type":"object"}},"type":"object"}}},"description":"Agent model profile"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Agent not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Agent model usage profile","tags":["Token Efficiency"]}},"/api/v1/knowledge/analytics/top-articles":{"get":{"callbacks":{},"description":"Returns the top accessed articles for the tenant in a time window. Supports `project_id` filtering and `group_by` (article|project|agent). Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeAnalyticsController.top_articles","parameters":[{"description":"Max rows per page (default 20, max 100). Clamped, never rejected.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Rows to skip — page the ranking to completeness (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Look back this many days (default 7, min 1, max 365)","in":"query","name":"since_days","required":false,"schema":{"type":"integer"}},{"description":"Restrict to a single access type (search, get, context, index)","in":"query","name":"access_type","required":false,"schema":{"type":"string"}},{"description":"Filter events to a single project_id (events without attribution are excluded)","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Grouping dimension: article (default), project, or agent","in":"query","name":"group_by","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Top articles"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Top accessed knowledge articles","tags":["Knowledge Analytics"]}},"/api/v1/knowledge/lint":{"get":{"callbacks":{},"description":"Analyzes published articles and returns a structured report of potential issues including stale articles, orphaned articles, contradiction clusters, coverage gaps, and broken source references. Read-only operation. When called via GET /projects/:project_id/knowledge/lint, scopes analysis to project-specific and tenant-wide articles. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeLintController.lint","parameters":[{"description":"Project UUID (optional, for project-scoped lint)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Number of days without update before an article is considered stale (default 90)","in":"query","name":"stale_days","required":false,"schema":{"type":"integer"}},{"description":"Minimum published articles per category to avoid a coverage gap (default 3)","in":"query","name":"min_coverage","required":false,"schema":{"type":"integer"}},{"description":"Maximum items returned per issue category (default 50, max 500). Totals before capping are returned in summary.total_per_category.","in":"query","name":"max_per_category","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Lint findings grouped by issue type","type":"object"},"summary":{"properties":{"generated_at":{"type":"string"},"issues_by_severity":{"type":"object"},"total_articles":{"type":"integer"},"total_issues":{"type":"integer"}},"type":"object"}},"type":"object"}}},"description":"Knowledge lint report"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Knowledge lint report","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/bulk-unpublish":{"post":{"callbacks":{},"description":"Unpublishes (published → draft) articles **partial-success** style — the mirror of bulk-publish, for cleanup passes. Every currently-published id is moved back to draft; each other id gets a per-id `outcome`: `unpublished`; `skipped` (with `reason` `already_draft` — idempotent — or `not_unpublishable_from_archived`/`not_unpublishable_from_superseded`); `not_found`; or `errored` (`reason` `unpublish_failed`). **A 200 does NOT mean everything unpublished** — inspect `meta.counts`. Duplicate ids de-duplicated; auto-chunked server-side (each chunk its own transaction, failing chunk retried row-by-row); bounded to 5000 ids (400 above). `meta.count` = number actually unpublished; `meta.counts` has requested/unpublished/skipped/not_found/errored; `meta.results` is the per-id breakdown in request order. `data` is the body-less summaries of the affected articles. Role: user+.","operationId":"LoopctlWeb.ArticleWorkflowController.bulk_unpublish","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"article_ids":{"items":{"format":"uuid","type":"string"},"type":"array"}},"required":["article_ids"],"type":"object"}}},"description":"Bulk unpublish params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"type":"object"}},"type":"object"}}},"description":"Bulk unpublish result (partial success; see meta.results / meta.counts)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request (empty article_ids)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Bulk unpublish articles","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/conflicts/resolve":{"post":{"callbacks":{},"description":"Record how a potential-conflict pair should be resolved. `dismiss` (false positive) takes effect immediately; `supersede` (with authoritative_article_id) is applied by the nightly executor at confidence \"high\" — it creates a supersedes link and retires the loser (reversible, audited); `merge` is recorded for the later LLM step (it produces a new DRAFT, never auto-published). The KB never re-judges — it acts on your verdict. Last-write-wins per pair. Only pairs the system flagged (GET /knowledge/conflicts) may be resolved; an unknown pair returns 422. All dispositions are agent+ KB-content curation (#331): they are reversible + audited, and the privileged nightly executor is what actually applies supersede/merge.","operationId":"LoopctlWeb.ArticleWorkflowController.resolve_conflict","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"authoritative_article_id":{"type":"string"},"classification":{"enum":["redundant","complementary","contradictory"],"type":"string"},"confidence":{"enum":["high","medium","low"],"type":"string"},"disposition":{"enum":["dismiss","supersede","merge"],"type":"string"},"evidence":{"type":"string"},"source_article_id":{"type":"string"},"target_article_id":{"type":"string"}},"required":["source_article_id","target_article_id","disposition"],"type":"object"}}},"description":"Resolution","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Recorded"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error, or no system-flagged potential_conflict for the pair"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Record a verdict on a potential-conflict pair","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/embeddings":{"get":{"callbacks":{},"description":"The tenant's active embedding dimension, whether semantic recall is currently available (and the reason when it is not), the instance's supported dimension set, the shared system corpus's materialization state, and re-embed progress. Role: agent+.","operationId":"LoopctlWeb.KnowledgeEmbeddingController.status","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Embedding status"}},"summary":"Embedding dimension status","tags":["Knowledge Wiki"]}},"/api/v1/egress/posture":{"get":{"callbacks":{},"description":"Resolved embedding + chat endpoints with a locality VERDICT for each, the tenant's declared trusted endpoints (labelled 'tenant-declared (unverified attestation), not network-local'), per-scope local_only/encrypt_body, and named posture defects. Endpoints are shown; KEYS NEVER ARE. Deployment-allowlist CONTENTS appear only at role :user+ — at :agent each endpoint carries only a boolean saying whether the verdict came from the allowlist. Role :agent.","operationId":"LoopctlWeb.EgressController.posture","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Egress posture"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"}},"summary":"Egress posture","tags":["Egress"]}},"/api/v1/memory/promote":{"post":{"callbacks":{},"description":"Triggers promotion of the caller's own session `session_id` into durable long-term `:promoted` memories via `Loopctl.Memory.promote_session/1`. Scope (`tenant_id`, `subject_id`) is derived from the API key — a caller may only promote its OWN (tenant, subject) sessions; any tenant/subject in the body is ignored, only `session_id` is read. Returns 202 with the enqueued job reference on success; returns 429 (standard error envelope) when the tenant is over its per-hour promotion budget, WITHOUT enqueuing or calling the LLM. Subject to the full :authenticated write chain (custody halt, witness header, rate limiting).","operationId":"LoopctlWeb.MemoryController.promote","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryPromoteRequest"}}},"description":"Promote params","required":false},"responses":{"202":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryPromoteResponse"}}},"description":"Promotion enqueued"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Missing session_id or subject/tenant unresolvable"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromotionBudgetError"}}},"description":"Promotion budget exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Promotion could not be enqueued (server error)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Tenant custody halted"}},"summary":"Promote (trigger session→long-term promotion)","tags":["Agent Memory"]}},"/api/v1/stories/bulk/verify":{"post":{"callbacks":{},"description":"Orchestrator verifies multiple reported_done stories.","operationId":"LoopctlWeb.BulkOperationsController.verify","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkVerifyRequest"}}},"description":"Verify params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResultResponse"}}},"description":"Results"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid input"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Bulk verify stories","tags":["Progress"]}},"/api/v1/agents":{"get":{"callbacks":{},"description":"Lists agents for the current tenant. Requires orchestrator+ role.","operationId":"LoopctlWeb.AgentController.index","parameters":[{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}},{"description":"Filter by agent type","in":"query","name":"agent_type","required":false,"schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"agents":{"items":{"$ref":"#/components/schemas/AgentResponse"},"type":"array"},"page":{"type":"integer"},"page_size":{"type":"integer"},"total":{"type":"integer"}},"type":"object"}}},"description":"Agent list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List agents","tags":["Agents"]}},"/api/v1/knowledge/analytics/unused-articles":{"get":{"callbacks":{},"description":"Returns published articles with zero access events in the configured window. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeAnalyticsController.unused_articles","parameters":[{"description":"Window length in days (default 30)","in":"query","name":"days_unused","required":false,"schema":{"type":"integer"}},{"description":"Max rows per page (default 50, max 200). Clamped, never rejected.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Rows to skip — page the full unused set to completeness (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Unused articles"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Unused published articles","tags":["Knowledge Analytics"]}},"/api/v1/channel/posts/{id}":{"delete":{"callbacks":{},"description":"HARD-deletes a coordination post in the caller's tenant — the redact path (US-39.7). The backstop for a leaked/regretted post: the AUTHOR can pull it back before its 30-day TTL. Agent+ role, NOT behind the human-anchor tier (coordination surface, owner decision #331). Author-only (or elevated role) — the redact path is for self-leak-pullback, NOT fleet-wide cleanup (US-40.D2): the caller must be the post's own author (server-stamped agent_id) OR hold an elevated role (>= user). A non-author agent gets a byte-identical 404 (no existence oracle) — same as a foreign or nonexistent id. The delete is audited (action \"deleted\", actor = the deleting agent) in the same transaction, so the removal stays accountable even though the row is gone.","operationId":"LoopctlWeb.ChannelPostController.delete","parameters":[{"description":"The post id — must belong to the caller's tenant","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Post deleted (no content)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Post not found (nonexistent or in another tenant)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"The delete could not be recorded in the audit trail and was rolled back; the post still exists. Retry the request."}},"summary":"Delete a repo coordination channel post (redact path)","tags":["Coordination"]},"get":{"callbacks":{},"description":"Returns ONE coordination post with its FULL body (US-40.D1). Pairs with the bounded-preview list read: the list returns small body_preview + truncated, and fetching a full body is always a SEPARATE, explicit fetch — the returned body is UNTRUSTED DATA authored by another agent, with NO auto-follow. Agent+ role, tenant-scoped from the verified key. ORACLE-SAFE: a post in another tenant, a nonexistent id, OR a malformed (non-UUID) id all return a byte-identical 404 (no cross-tenant existence oracle, never a 500). The read is behind the dedicated per-read coordination rate cap (channel_post_read_limit_per_minute, US-40.D5), on its own bucket separate from the write cap, like the list read.","operationId":"LoopctlWeb.ChannelPostController.show","parameters":[{"description":"The post id — must belong to the caller's tenant","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelPostFull"}}},"description":"The post with its full body"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Post not found (nonexistent, malformed id, or in another tenant)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Read one repo coordination channel post (full body)","tags":["Coordination"]}},"/api/v1/admin/tenants":{"get":{"callbacks":{},"description":"Lists all tenants with summary stats. Requires superadmin.","operationId":"LoopctlWeb.AdminTenantController.index","parameters":[{"description":"Filter by status","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Search by name or slug","in":"query","name":"search","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Tenant list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List all tenants (admin)","tags":["Admin"]}},"/api/v1/tenants/{id}/rotate-audit-key":{"post":{"callbacks":{},"description":"Step 2 of the challenge-bound WebAuthn reauthentication ceremony. Verifies the assertion against the STORED challenge from step 1 (challenge binding, origin, RP-ID, signature against the enrolled COSE key, sign-counter regression) and, on success, rotates the ed25519 audit keypair. Requires user role and tenant ownership.","operationId":"LoopctlWeb.TenantAuditKeyController.rotate","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateAuditKeyRequest"}}},"description":"WebAuthn assertion","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditKeyResponse"}}},"description":"Key rotated"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"WebAuthn required or failed"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"No audit key to rotate"}},"summary":"Rotate the tenant audit signing key","tags":["Tenants"]}},"/api/v1/stories/{id}/contract":{"post":{"callbacks":{},"description":"Agent acknowledges the story's acceptance criteria. Transitions pending -> contracted.","operationId":"LoopctlWeb.StoryStatusController.contract","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ContractRequest"}}},"description":"Contract params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story contracted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Mismatch"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Contract story","tags":["Progress"]}},"/api/v1/ingestion-anomalies/{id}":{"patch":{"callbacks":{},"description":"Marks an ingestion capture-silence anomaly as resolved. Pass ?archived=true to instead ARCHIVE it — the escape hatch for a retired source_type, which hides it from the default list and suppresses re-detection. Pass ?archived=false to UN-ARCHIVE it, restoring monitoring for a mistakenly-archived source_type. A malformed ?archived value is rejected with 422.","operationId":"LoopctlWeb.IngestionAnomalyController.update","parameters":[{"description":"Anomaly UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"true = archive (suppress re-detection); false = un-archive (restore monitoring); omit = resolve","in":"query","name":"archived","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"description":"Confirmation of resolution or archival","properties":{"ingestion_anomaly":{"properties":{"archived":{"example":false,"type":"boolean"},"id":{"format":"uuid","type":"string"},"resolved":{"example":true,"type":"boolean"},"updated_at":{"format":"date-time","type":"string"}},"type":"object"}},"type":"object"}}},"description":"Anomaly resolved or archived"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Anomaly not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Resolve, archive, or un-archive ingestion anomaly","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/conflicts":{"get":{"callbacks":{},"description":"Lists `:potential_conflict` pairs — published articles flagged 'too similar to comfortably coexist' by the auto-linker / nightly lint sweep, highest-overlap first. The KB only flags; the caller decides whether each is a redundancy to merge or a real contradiction to reconcile. Role: agent+.","operationId":"LoopctlWeb.ArticleWorkflowController.conflicts","parameters":[{"description":"Max results per page (default 50, max 1000). A limit above the max is clamped to the maximum — never rejected — so pagination stays complete.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Records to skip","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"type":"object"}},"type":"object"}}},"description":"Conflicts list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List potential-conflict article pairs","tags":["Knowledge Wiki"]}},"/api/v1/custody/failures":{"get":{"callbacks":{},"description":"Recording failures are surfaced, never silently dropped: each entry here degrades its row's claim to 'incomplete'. `stale_pending` lists entries that have been in flight longer than the stale window — a flush that died outside its own final-attempt error branch never marks anything failed, so without this those rows would read as an in-flight claim indefinitely and appear nowhere. Role :agent.","operationId":"LoopctlWeb.CustodyClaimController.failures","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Failed and stranded custody posture entries"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"}},"summary":"Custody posture entries whose chain append was dropped or stranded","tags":["Custody"]}},"/api/v1/knowledge/llm-usage":{"get":{"callbacks":{},"description":"Returns token usage for the current tenant grouped by operation + model + source_type + day, newest day first, with offset/limit pagination over `meta.total_count`. Optional `from`/`to` (ISO 8601) narrow the window. Record-only — no budget enforcement. Role: orchestrator+.","operationId":"LoopctlWeb.LlmUsageController.index","parameters":[{"description":"Optional ISO 8601 lower bound (inclusive) on occurred_at. Defaults to a 90-day lookback when omitted; the effective window is echoed in `meta.from`/`meta.to`.","in":"query","name":"from","required":false,"schema":{"type":"string"}},{"description":"Optional ISO 8601 upper bound (inclusive) on occurred_at","in":"query","name":"to","required":false,"schema":{"type":"string"}},{"description":"Max rows per page (default 50, clamped to 200)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Rows to skip (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LlmUsageResponse"}}},"description":"Usage summary"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Per-tenant LLM usage summary","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/okf/export":{"get":{"callbacks":{},"description":"Exports published articles as an OKF v0.1 bundle. Defaults to a streamed gzipped tar archive (bounded memory, no article-count cap, fail-closed on mid-stream error); pass format=json for a `{files, meta}` JSON payload (buffered in memory — for tooling that writes the files itself — and so capped at export_max_buffered_export_articles, 413 over it). Each concept's `# Related` list is capped at export_max_links_per_article (default 100) per direction; a capped concept carries `loopctl_links_truncated: true` in frontmatter. When called via GET /projects/:project_id/knowledge/okf/export, includes tenant-wide + project articles. Role: user+.","operationId":"LoopctlWeb.OKFController.export (2)","parameters":[{"description":"","in":"path","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"tar.gz (default) or json","in":"query","name":"format","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/gzip":{"schema":{"format":"binary","type":"string"}}},"description":"OKF bundle (.tar.gz, chunked)"},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"format=json bundle exceeds the buffered-export cap (use the streamed .tar.gz)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Too many concurrent exports"}},"summary":"Export knowledge as an OKF bundle (streamed .tar.gz)","tags":["Knowledge Wiki"]}},"/api/v1/token-usage/{id}/correction":{"post":{"callbacks":{},"description":"Creates a correction report referencing the original. Allows negative input_tokens, output_tokens, cost_millicents. Returns 422 if the correction would make any total negative. Only users (not agents) may create corrections.","operationId":"LoopctlWeb.TokenUsageController.correct","parameters":[{"description":"Original report UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"cost_millicents":{"type":"integer"},"input_tokens":{"type":"integer"},"metadata":{"additionalProperties":true,"type":"object"},"model_name":{"minLength":1,"type":"string"},"output_tokens":{"type":"integer"},"phase":{"enum":["planning","implementing","reviewing","other"],"type":"string"},"session_id":{"nullable":true,"type":"string"}},"type":"object"}}},"description":"Correction params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"token_usage_report":{"$ref":"#/components/schemas/TokenUsageReport"}},"type":"object"}}},"description":"Correction created"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Original report not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error or negative totals"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create a correction report","tags":["Token Efficiency"]}},"/api/v1/stories/blocked":{"get":{"callbacks":{},"description":"Returns stories blocked by unverified dependencies.","operationId":"LoopctlWeb.DependencyGraphController.blocked","parameters":[{"description":"Filter by project","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Blocked stories"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List blocked stories","tags":["Dependencies"]}},"/api/v1":{"get":{"callbacks":{},"description":"Returns links to the OpenAPI spec, Swagger UI, and health check.","operationId":"LoopctlWeb.WelcomeController.index","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"discovery":{"example":"/.well-known/loopctl","type":"string"},"docs":{"example":"/api/v1/openapi","type":"string"},"health":{"example":"/health","type":"string"},"mcp_server":{"description":"Recommended interface for AI coding agents (no curl needed)","properties":{"npm":{"example":"loopctl-mcp-server","type":"string"},"registry":{"example":"https://www.npmjs.com/package/loopctl-mcp-server","type":"string"}},"type":"object"},"name":{"example":"loopctl","type":"string"},"routes":{"example":"/api/v1/routes","type":"string"},"swagger_ui":{"example":"/swaggerui","type":"string"},"version":{"example":"1.0.0","type":"string"},"wiki":{"example":"/wiki","type":"string"}},"type":"object"}}},"description":"Welcome response"}},"security":[],"summary":"API discovery endpoint","tags":["Discovery"]}},"/api/v1/recall":{"post":{"callbacks":{},"description":"Returns ONE merged, re-ranked result combining the caller's long-term MEMORY recall AND the KNOWLEDGE combined search for `(query, project_id)` — the `global ∪ active-project` union the harness previously assembled by calling `/memory/recall` and `/knowledge/search` separately (#411 Gap 2). NOTE the knowledge half is the COMBINED SEARCH: article SUMMARIES (id/title/category/tags/score + a truncated snippet), NOT the deep-read `/knowledge/context` (full article bodies + one-hop linked references + recency weighting). Callers needing full bodies or linked refs must still call `/knowledge/context`. Both sides merge global with the active project: an absent/blank `project_id` returns GLOBAL-ONLY memory AND global-only knowledge; a present `project_id` merges global with that project on BOTH sides (another project's rows are excluded). `project_id` is a PARTITION key, NOT the isolation boundary — `(tenant_id, subject_id)` is, and is derived from the API key, never the body. A malformed `project_id` is a 422 (`invalid_project_id`); a non-string, missing, or blank/whitespace-only `query` is a 422 (`invalid_query`); a query longer than 500 characters is a 422 (`query_too_long`) — rejected up front (matching `/knowledge/search`) BEFORE any embedding is generated, never a half-degraded memory-only 200. The response carries the merged `results` (each tagged `source: memory|knowledge`, sorted by a heuristically-comparable `score` DESC — `meta.results_ranking` is `heuristic_cross_source`) PLUS the untouched per-source `memory` and `knowledge` envelopes so callers can re-rank. Cross-source scores are heuristic, not calibrated (memory = absolute cosine similarity; knowledge = pool-normalized keyword+semantic, which biases knowledge UPWARD in the default order). On a DEGRADED knowledge side (keyword-only fallback) memory rows carry absolute cosine scores while knowledge rows carry raw (un-normalized) keyword `relevance_score`, which can outrank memory in the merged `data` — callers who need memory-first ordering under degradation should read the per-source `memory` envelope (it preserves the honest native scores). If the knowledge search errors or degrades to keyword-only, OR the memory heavy-read pool is shed under the per-tenant cap, the OTHER side is still returned and `meta.degraded?` is true (`meta.degraded_reason` names why) — never a 500 and never a whole-endpoint 429 from one shed pool. Agent role is forced to published articles and its own/`shared` memories (#163).","operationId":"LoopctlWeb.MemoryController.context","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecallContextRequest"}}},"description":"Recall params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecallContextResponse"}}},"description":"Merged recall results"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Subject unresolvable, non-string/blank/over-length query, or invalid project_id"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Tenant custody halted"}},"summary":"Merged recall (memory ∪ knowledge, one round-trip)","tags":["Agent Memory"]}},"/api/v1/articles":{"get":{"callbacks":{},"description":"Lists articles with optional filters and pagination. When called via GET /projects/:project_id/articles, project_id is set from path. Unlike search (which ranks and returns **published** articles only and lags writes while embeddings index), this is the **lag-free, all-status** read of the DB of record — use it for dedup/idempotency/repair (\"does an article with this tag/source/idempotency_key exist?\"). `meta.total_count` is the exact filtered count. **Returns a body-less summary by default** (safe to enumerate up to limit=1000); pass `include_body=true` to also return `body`, which bounds the page by a ~5 MB serialized-body budget and adds `meta.next_offset`/`meta.has_more`/`meta.byte_truncated` for continuation. For a single full body use GET /articles/:id. Role: agent+.","operationId":"LoopctlWeb.ArticleController.index","parameters":[{"description":"Filter by category (pattern|convention|decision|finding|reference)","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Filter by status (draft|published|archived|superseded)","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Filter by tags (comma-separated). Match mode set by `match` (default ANY).","in":"query","name":"tags","required":false,"schema":{"type":"string"}},{"description":"Tag match mode: `any` (default, OR — overlaps any listed tag) or `all` (AND — carries every listed tag, e.g. tags=book,hub&match=all = \"book hubs\").","in":"query","name":"match","required":false,"schema":{"type":"string"}},{"description":"Filter by source_type","in":"query","name":"source_type","required":false,"schema":{"type":"string"}},{"description":"Filter by source_id","in":"query","name":"source_id","required":false,"schema":{"type":"string"}},{"description":"Filter by exact idempotency_key (lag-free existence check)","in":"query","name":"idempotency_key","required":false,"schema":{"type":"string"}},{"description":"Max results per page (default 20, max 1000). A limit above the max is clamped to the maximum — never rejected — so offset pagination stays complete.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Records to skip","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Include full article body (default false). When true the page is bounded by a ~5 MB serialized-body budget and may return fewer than `limit` rows; continue via `meta.next_offset` while `meta.has_more` is true.","in":"query","name":"include_body","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"type":"array"},"meta":{"type":"object"}},"type":"object"}}},"description":"Article list"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid filter value (e.g. unknown status/category) — body lists allowed values"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List articles","tags":["Knowledge Wiki"]},"post":{"callbacks":{},"description":"Creates a tenant-wide or project-scoped article. When called via POST /projects/:project_id/articles, project_id is set from path. Articles are **published immediately by default** (visible in search/index/context) for every role, including agent. To stage an article for later review instead, pass `draft: true` (or `status: \"draft\"`); the response `note` says which outcome occurred. The initial status is set by the server — a caller-supplied `status` is ignored except that `status: \"draft\"` is honoured as the draft opt-in (so archived/superseded can't be conjured at create time; those are workflow transitions). Role: agent+.","operationId":"LoopctlWeb.ArticleController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"body":{"type":"string"},"category":{"enum":["pattern","convention","decision","finding","reference"],"type":"string"},"draft":{"description":"Stage as a draft instead of publishing on create. Default false (article is published immediately). Equivalent alias: status: \"draft\". Publishing a staged draft afterwards (POST /articles/:id/publish) requires orchestrator role; publish-on-create does not. (Note: the ingestion path has the OPPOSITE polarity — POST /knowledge/ingest is draft-by-default; pass publish: true there.)","type":"boolean"},"idempotency_key":{"description":"Optional stable per-article key for idempotent capture (max 255). Re-creating with the same key is a no-op that returns a REFERENCE to the existing article (200, `deduplicated: true`, id only — not its body) — regardless of the body sent, and ahead of the title-conflict check; a changed title/body is NOT applied (PATCH /articles/:id to change it). Use a HIGH-ENTROPY value (e.g. a content hash): it is a per-tenant lookup key, not a secret. Distinct from source_type/source_id, which identify a shared source. Set at create time only (ignored by PATCH); applies to tenant-scoped articles.","nullable":true,"type":"string"},"metadata":{"additionalProperties":true,"type":"object"},"project_id":{"format":"uuid","nullable":true,"type":"string"},"source_id":{"format":"uuid","nullable":true,"type":"string"},"source_type":{"nullable":true,"type":"string"},"tags":{"items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["title","body","category"],"type":"object"}}},"description":"Article params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Idempotent dedup, returned unchanged with `deduplicated: true`: either an active article with the same title and an identical body exists, OR an article with the same `idempotency_key` exists (in which case a changed title/body is NOT applied). The `note` says which."},"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Article created (response includes a `note`; `status` is published unless draft)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"System scope requested without superadmin role"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Title taken by an article with different content"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create article","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/articles/{id}/stats":{"get":{"callbacks":{},"description":"Returns aggregated access counts for a single article. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeAnalyticsController.article_stats","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Article stats"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Per-article usage statistics","tags":["Knowledge Analytics"]}},"/api/v1/knowledge/curation-log":{"get":{"callbacks":{},"description":"The concise, human-readable log of KB CURATION adjustments (novelty-gate decisions, conflict supersede/merge/dismiss) — the 'what did the KB change' feed for rollout analysis. Recorded only while the tenant has `settings.kb_curation_log` on (toggle via PATCH /api/v1/admin/tenants/:id). Most recent first. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeAnalyticsController.curation_log","parameters":[{"description":"Filter by kind (gate_duplicate|gate_draft|supersede|merge|dismiss)","in":"query","name":"kind","required":false,"schema":{"type":"string"}},{"description":"ISO8601 date/datetime lower bound (inclusive)","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"Events per page (default 50, max 500). Clamped, never rejected.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Events to skip (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Curation log"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"KB curation adjustment log","tags":["Knowledge Analytics"]}},"/api/v1/projects":{"get":{"callbacks":{},"description":"Lists projects for the current tenant with pagination.","operationId":"LoopctlWeb.ProjectController.index","parameters":[{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}},{"description":"Filter by status (active/archived)","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Include archived projects","in":"query","name":"include_archived","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/ProjectResponse"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Project list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List projects","tags":["Projects"]},"post":{"callbacks":{},"description":"Creates a new project. Requires user+ role.","operationId":"LoopctlWeb.ProjectController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreateRequest"}}},"description":"Project params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Project created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create project","tags":["Projects"]}},"/api/v1/stories/bulk/mark-complete":{"post":{"callbacks":{},"description":"ADMIN USE ONLY. Marks multiple stories as both reported_done AND verified in one step. Intended for importing pre-existing work. Skips the normal contract→claim→start→report→verify workflow. Requires orchestrator role.","operationId":"LoopctlWeb.BulkOperationsController.mark_complete","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"example":{"stories":[{"review_type":"pre_existing","story_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","summary":"Pre-existing on master"}]},"properties":{"stories":{"items":{"properties":{"review_type":{"description":"Review type label","example":"pre_existing","type":"string"},"story_id":{"format":"uuid","type":"string"},"summary":{"description":"Brief summary of the pre-existing work","example":"Pre-existing on master","type":"string"}},"required":["story_id"],"type":"object"},"type":"array"}},"required":["stories"],"type":"object"}}},"description":"Mark-complete params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResultResponse"}}},"description":"Results"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid input"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Bulk mark stories as complete","tags":["Progress"]}},"/api/v1/epics/{id}/verify-all":{"post":{"callbacks":{},"description":"Orchestrator convenience endpoint that verifies all stories in the epic that have agent_status=reported_done and verified_status=unverified. Requires review_type and summary in the body (same as single verify). Returns count of verified stories and any errors.","operationId":"LoopctlWeb.StoryVerificationController.verify_all","parameters":[{"description":"Epic UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyRequest"}}},"description":"Verification params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"errors":{"items":{"type":"object"},"type":"array"},"skipped_count":{"example":0,"type":"integer"},"total_eligible":{"example":5,"type":"integer"},"verified_count":{"example":5,"type":"integer"}},"type":"object"}}},"description":"Verify-all result"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Verify all reported-done stories in an epic","tags":["Progress"]}},"/api/v1/analytics/projects/{id}":{"get":{"callbacks":{},"description":"Returns comprehensive cost overview for a single project including phase breakdown, model breakdown, and budget utilization.","operationId":"LoopctlWeb.AnalyticsController.project","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"$ref":"#/components/schemas/TokenAnalyticsProject"}},"type":"object"}}},"description":"Project metrics"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Project not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Single project cost overview","tags":["Token Efficiency"]}},"/api/v1/epics/{id}/story_dependencies":{"get":{"callbacks":{},"description":"Lists story dependency edges for stories in an epic.","operationId":"LoopctlWeb.StoryDependencyController.index","parameters":[{"description":"Epic UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Dependencies"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List story dependencies","tags":["Dependencies"]}},"/api/v1/api_keys":{"get":{"callbacks":{},"description":"Lists all API keys for the current tenant. Never exposes raw key or hash.","operationId":"LoopctlWeb.ApiKeyController.index","parameters":[{"description":"Include revoked keys","in":"query","name":"include_revoked","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"api_keys":{"items":{"$ref":"#/components/schemas/ApiKeyResponse"},"type":"array"}},"type":"object"}}},"description":"API key list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List API keys","tags":["Auth"]},"post":{"callbacks":{},"description":"Creates a new API key. Returns the raw key once. Requires user role.","operationId":"LoopctlWeb.ApiKeyController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreateRequest"}}},"description":"API key params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyResponse"}}},"description":"API key created"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create API key","tags":["Auth"]}},"/api/v1/stories/{id}/verify":{"post":{"callbacks":{},"description":"Orchestrator verifies a reported_done story. Creates verification_result with result=pass. Requires a review_record to exist (call POST /stories/:id/review-complete first). The review_record must have been completed AFTER the story was reported done.","operationId":"LoopctlWeb.StoryVerificationController.verify","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyRequest"}}},"description":"Verification params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story verified"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Invalid transition"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"No review record found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Verify story","tags":["Progress"]}},"/api/v1/admin/audit":{"get":{"callbacks":{},"description":"Returns paginated audit log entries across all tenants. Requires superadmin.","operationId":"LoopctlWeb.AdminAuditController.index","parameters":[{"description":"Filter by tenant","in":"query","name":"tenant_id","required":false,"schema":{"type":"string"}},{"description":"Filter by entity type","in":"query","name":"entity_type","required":false,"schema":{"type":"string"}},{"description":"Filter by entity ID","in":"query","name":"entity_id","required":false,"schema":{"type":"string"}},{"description":"Filter by action","in":"query","name":"action","required":false,"schema":{"type":"string"}},{"description":"Filter by actor type","in":"query","name":"actor_type","required":false,"schema":{"type":"string"}},{"description":"Filter by actor ID","in":"query","name":"actor_id","required":false,"schema":{"type":"string"}},{"description":"ISO8601 start time","in":"query","name":"from","required":false,"schema":{"type":"string"}},{"description":"ISO8601 end time","in":"query","name":"to","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Audit log"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Cross-tenant audit log (admin)","tags":["Admin"]}},"/api/v1/projects/{id}/import":{"post":{"callbacks":{},"description":"Imports a work breakdown into a project. Use merge=true for merge import.","operationId":"LoopctlWeb.ImportExportController.import_project","parameters":[{"description":"Project UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Merge mode (update existing)","in":"query","name":"merge","required":false,"schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImportRequest"}}},"description":"Import data","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Import summary"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Project not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Conflict"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Import work breakdown","tags":["Import/Export"]}},"/api/v1/memory":{"get":{"callbacks":{},"description":"Lists the caller's own long-term memories, newest first, paginated with `meta.total_count/limit/offset` (total is the true scoped count, never silently capped by `limit`). Optionally filter by provenance with `source=promoted|explicit` (US-29.3). Superadmin oversight: a superadmin key may pass `all_subjects=true` to list EVERY subject's memories within its tenant; the same parameter from a non-superadmin key is ignored (results stay confined to its own subject).","operationId":"LoopctlWeb.MemoryController.index","parameters":[{"description":"Page size (default 50, max 200)","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Records to skip (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Include superseded memories (default false)","in":"query","name":"include_superseded","required":false,"schema":{"type":"boolean"}},{"description":"Filter by provenance — one of `promoted` (session→long-term promotions) or `explicit` (directly written). Any other/omitted value → no filter.","in":"query","name":"source","required":false,"schema":{"type":"string"}},{"description":"Superadmin only: list all subjects' memories in the tenant. Ignored for non-superadmin keys.","in":"query","name":"all_subjects","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryListResponse"}}},"description":"Memory list"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Subject unresolvable"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List memories","tags":["Agent Memory"]},"post":{"callbacks":{},"description":"Writes a memory under the caller's own `(tenant_id, subject_id)` scope, derived from the API key — NOT from the body (any tenant_id/subject_id in the body is ignored). `tier` selects the substrate: `long_term` (default; requires `text`, embedded asynchronously and recalled by semantic similarity) or `session` (short-term; requires `session_id` and `content`; `expires_at` is OPTIONAL — the server defaults it to now + the session-memory TTL and floors any supplied value up to the promotion sweep window, so a turn is always promoted before it can be pruned). An optional `project_id` (UUID) partitions the memory to a project; absent/blank writes a tenant-wide (global) memory. `project_id` is a partition key, NOT an isolation boundary, but it is validated for tenant-ownership — a malformed value, or a well-formed UUID that is not a project in the caller's own tenant, is rejected with a 422 (`invalid_project_id`) rather than persisted. Returns 201 with the created memory. Subject to the full :authenticated chain (custody halt, witness header, rate limiting).","operationId":"LoopctlWeb.MemoryController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryCreateRequest"}}},"description":"Memory params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryResponse"}}},"description":"Memory created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error, quota exceeded, invalid project_id, or subject unresolvable"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Tenant custody halted"}},"summary":"Remember (write a memory)","tags":["Agent Memory"]}},"/api/v1/analytics/agents":{"get":{"callbacks":{},"description":"Returns per-agent cost metrics including efficiency ranking. Filterable by project_id and date range.","operationId":"LoopctlWeb.AnalyticsController.agents","parameters":[{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Start date (YYYY-MM-DD)","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"End date (YYYY-MM-DD)","in":"query","name":"until","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/TokenAnalyticsAgent"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Agent metrics"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Per-agent cost metrics","tags":["Token Efficiency"]}},"/api/v1/kb-scopes/{id}/restore":{"post":{"callbacks":{},"description":"Re-activates an archived kind: kb scope owned by the tenant (the reverse of DELETE /kb-scopes/:id). Agent+ role, not human-anchor gated. Re-activating consumes an active max_projects slot, so it is rejected 422 when the tenant is at its cap. A kind: work project is rejected 422.","operationId":"LoopctlWeb.ProjectController.restore_kb_scope","parameters":[{"description":"KB scope (project) UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"Restored KB scope"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not a KB scope, or project limit reached"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Restore (un-archive) a knowledge-only project scope","tags":["Projects"]}},"/api/v1/analytics/model-mix":{"get":{"callbacks":{},"description":"Returns a (model_name, phase) correlation matrix with token totals, cost, stories count, and verification outcomes. Includes comparative view: mixed-model vs single-model agent averages. Filterable by project_id, agent_id, and date range.","operationId":"LoopctlWeb.AnalyticsController.model_mix","parameters":[{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by agent UUID","in":"query","name":"agent_id","required":false,"schema":{"type":"string"}},{"description":"Start date (YYYY-MM-DD)","in":"query","name":"since","required":false,"schema":{"type":"string"}},{"description":"End date (YYYY-MM-DD)","in":"query","name":"until","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Model-mix correlation matrix keyed by (model_name, phase). Includes comparative view: mixed-model vs single-model agent averages.","properties":{"comparative":{"additionalProperties":true,"description":"Mixed-model vs single-model agent average cost comparison","type":"object"},"matrix":{"items":{"$ref":"#/components/schemas/ModelMixEntry"},"type":"array"}},"type":"object"}},"type":"object"}}},"description":"Model-mix matrix"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Model-mix correlation matrix","tags":["Token Efficiency"]}},"/api/v1/skills/{id}":{"delete":{"callbacks":{},"description":"Archives a skill (soft delete).","operationId":"LoopctlWeb.SkillController.delete","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}},"description":"Archived skill"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Archive skill","tags":["Skills"]},"get":{"callbacks":{},"description":"Returns skill detail with current version prompt.","operationId":"LoopctlWeb.SkillController.show","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}},"description":"Skill detail"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get skill","tags":["Skills"]},"patch":{"callbacks":{},"description":"Updates skill description, status, or metadata.","operationId":"LoopctlWeb.SkillController.update (2)","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}},"description":"Updated skill"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update skill metadata","tags":["Skills"]},"put":{"callbacks":{},"description":"Updates skill description, status, or metadata.","operationId":"LoopctlWeb.SkillController.update","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}},"description":"Updated skill"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update skill metadata","tags":["Skills"]}},"/api/v1/skills/{id}/versions":{"get":{"callbacks":{},"description":"Lists all versions of a skill.","operationId":"LoopctlWeb.SkillController.list_versions","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/SkillVersionResponse"},"type":"array"}},"type":"object"}}},"description":"Version list"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List skill versions","tags":["Skills"]},"post":{"callbacks":{},"description":"Creates a new version of a skill with updated prompt text.","operationId":"LoopctlWeb.SkillController.create_version","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"changelog":{"nullable":true,"type":"string"},"created_by":{"nullable":true,"type":"string"},"prompt_text":{"type":"string"}},"required":["prompt_text"],"type":"object"}}},"description":"Version params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillVersionResponse"}}},"description":"Version created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Skill not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create skill version","tags":["Skills"]}},"/api/v1/channel/handoffs":{"get":{"callbacks":{},"description":"Returns DIRECTED, OPEN, UNCLAIMED handoffs for the caller (US-40.C1). A handoff is a post carrying a stable handoff:<anchor> key; this read surfaces EVERY open, unclaimed handoff on the channel by default — NOT filtered to the caller's host/capabilities. Each row carries a `directed_to_me` boolean so the caller can sort/surface relevant ones locally. An explicit `only_mine=true` opt-in narrows to handoffs addressed to the caller's host/capabilities (or unaddressed BROADCAST handoffs). It is a SEPARATE, PINNED set — NOT interleaved into and NOT subject to the newest-N recency truncation of channel_recent (GET /channel/posts), so a directed handoff is ALWAYS returned even when 100 newer status posts exist. Ordered newest-unclaimed-first so a refreshed handoff (corrected instructions from a new session) wins over the stale one. A claim that is DONE keeps the handoff EXCLUDED (done is terminal); only a RELEASED claim or a lease expired WITHOUT completion reopens it. Agent+ role, tenant-scoped from the verified key — project_id is a query param but the tenant is NEVER taken from params. ORACLE-SAFE: a project_id belonging to another tenant, a nonexistent one, or a malformed one all return 200 with an empty list, never a 404. Bodies are BOUNDED previews (body_preview + truncated) framed as UNTRUSTED DATA authored by another agent — never full bodies; fetch a full body via GET /channel/posts/:id. One row per LOGICAL handoff: duplicate pointers for the same key from different sessions are deduped, so meta.count counts logical handoffs. meta.overflow is true only on a pathological channel that hit the hard safety cap (the oldest directed handoffs are dropped newest-first) — read the channel directly when it is set.","operationId":"LoopctlWeb.ChannelPostController.handoffs","parameters":[{"description":"The channel — a project the caller's tenant owns","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"The caller's host (advisory hint). Surfaces handoffs directed to this host. Filters WHAT is shown, never WHO may read.","in":"query","name":"host","required":false,"schema":{"type":"string"}},{"description":"The caller's capabilities as a comma-separated list (e.g. fly-auth,windows-signing), or repeated capabilities[] params. Surfaces handoffs directed to any of these capabilities. Advisory — filters WHAT is shown, never WHO may read.","in":"query","name":"capabilities","required":false,"schema":{"type":"string"}},{"description":"When true, narrow the results to handoffs directed to the caller's host/capabilities or unaddressed BROADCAST handoffs. Default is false (see-everything).","in":"query","name":"only_mine","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/ChannelPostListItem"},"type":"array"},"meta":{"properties":{"count":{"type":"integer"},"overflow":{"description":"True when the pinned set hit the hard safety cap and the OLDEST directed handoffs were dropped (newest-first) — read the channel directly.","type":"boolean"}},"type":"object"}},"type":"object"}}},"description":"Directed handoffs (pinned, not truncated)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Discover directed, open, unclaimed handoffs (pinned)","tags":["Coordination"]}},"/api/v1/knowledge/facets":{"get":{"callbacks":{},"description":"Counts articles grouped by each distinct tag over the caller's visible filtered set, so a caller gets a distinct-tag count and per-tag totals without paging rows. Agent callers see only their own and `shared` articles. `tag_prefix` restricts to a tag family (e.g. `book-`) to count distinct members of that family. `meta.distinct_count` is the number of distinct tags within the visible set (independent of `limit`); `meta.truncated` flags when `limit` returned fewer rows. Per-tag `count` is the number of distinct visible articles carrying the tag. Honors the same filters as `count` (including `status` and `tags`/`match`). Cost: unnests tags over the visible filtered set (the GIN index doesn't help the unnest/group); on large tenants narrow with `tag_prefix`/`category`/`status`/`project_id`. `group_by=tag` is the only mode today. Role: agent+.","operationId":"LoopctlWeb.KnowledgeFacetsController.facets","parameters":[{"description":"Facet dimension (only `tag`)","in":"query","name":"group_by","required":false,"schema":{"type":"string"}},{"description":"Only tags starting with this prefix","in":"query","name":"tag_prefix","required":false,"schema":{"type":"string"}},{"description":"Filter by category","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","required":false,"schema":{"type":"string"}},{"description":"Filter by tags (comma-separated)","in":"query","name":"tags","required":false,"schema":{"type":"string"}},{"description":"Tag match mode: any (default) or all","in":"query","name":"match","required":false,"schema":{"type":"string"}},{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Max distinct tags in the facet result (default all, max 1000). A limit above the max is clamped to the maximum — never rejected — so pagination stays complete.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"tag => count","type":"object"},"meta":{"properties":{"distinct_count":{"description":"True distinct-tag count, independent of limit","type":"integer"},"group_by":{"type":"string"},"tag_prefix":{"nullable":true,"type":"string"},"truncated":{"description":"True when limit returned fewer facet rows than distinct_count","type":"boolean"}},"type":"object"}},"type":"object"}}},"description":"Tag facets"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Tag facets (count by distinct tag)","tags":["Knowledge Wiki"]}},"/api/v1/webhooks/{id}":{"delete":{"callbacks":{},"description":"Deletes a webhook and all its pending events.","operationId":"LoopctlWeb.WebhookController.delete","parameters":[{"description":"Webhook UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Delete webhook","tags":["Webhooks"]},"patch":{"callbacks":{},"description":"Updates a webhook subscription.","operationId":"LoopctlWeb.WebhookController.update (2)","parameters":[{"description":"Webhook UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookResponse"}}},"description":"Updated webhook"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update webhook","tags":["Webhooks"]},"put":{"callbacks":{},"description":"Updates a webhook subscription.","operationId":"LoopctlWeb.WebhookController.update","parameters":[{"description":"Webhook UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookResponse"}}},"description":"Updated webhook"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update webhook","tags":["Webhooks"]}},"/api/v1/signup":{"post":{"callbacks":{},"description":"Creates an agent-rooted tenant and mints a one-time role:user root API key, entirely through this API call — no WebAuthn ceremony. The resulting tenant has full knowledge-wiki access but NOT the work-breakdown / chain-of-custody surface (see the RequireHumanAnchor gate). Public — no authentication required. Rate-limited per client IP (<= 5 signups/hour).","operationId":"LoopctlWeb.SignupController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SelfSignupRequest"}}},"description":"Signup params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SelfSignupResponse"}}},"description":"Tenant created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"security":[],"summary":"Self-signup (agent-rooted, KB tier)","tags":["Tenants"]}},"/api/v1/skill_results":{"post":{"callbacks":{},"description":"Records a skill execution result. Requires orchestrator role.","operationId":"LoopctlWeb.SkillResultController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"metrics":{"additionalProperties":true,"type":"object"},"skill_version_id":{"format":"uuid","type":"string"},"story_id":{"format":"uuid","type":"string"},"verification_result_id":{"format":"uuid","nullable":true,"type":"string"}},"required":["skill_version_id","story_id","metrics"],"type":"object"}}},"description":"Result params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Result created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Record skill result","tags":["Skills"]}},"/api/v1/tenants/{id}/bootstrap-audit-key":{"post":{"callbacks":{},"description":"Generates the initial ed25519 audit keypair for a tenant that predates the Chain of Custody v2 signup ceremony. Requires user role and tenant ownership. Refuses (409) if a key already exists — use rotate-audit-key.","operationId":"LoopctlWeb.TenantAuditKeyController.bootstrap","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditKeyResponse"}}},"description":"Audit keypair bootstrapped"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Key already exists"}},"summary":"Bootstrap an audit signing keypair for a legacy tenant","tags":["Tenants"]}},"/api/v1/stories/{id}/request-review":{"post":{"callbacks":{},"description":"Assigned agent signals that implementation is complete and ready for review. Does NOT change status. Fires a story.review_requested webhook event.","operationId":"LoopctlWeb.StoryStatusController.request_review","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Review requested"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not assigned agent"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Story not in implementing status"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Request review","tags":["Progress"]}},"/api/v1/knowledge/embeddings/system-corpus":{"post":{"callbacks":{},"description":"Enqueues the AC-41.1.7 on-demand per-tenant materialization of the SYSTEM-scoped article corpus at this tenant's active dimension, using this tenant's own embedding credential. Idempotent. Role: agent+.","operationId":"LoopctlWeb.KnowledgeEmbeddingController.system_corpus","parameters":[],"responses":{"202":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Enqueued"}},"summary":"Materialize the shared system corpus for this tenant","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/index":{"get":{"callbacks":{},"description":"Returns a lightweight catalog of published articles grouped by category. Each article object includes only the projected fields (default id, title, category — see `fields`). Agent callers see only articles they own (when `visibility` is `private` or `owner`) or marked `shared`; higher roles see all articles. When called via GET /projects/:project_id/knowledge/index, includes both tenant-wide and project-specific articles. Honors category/tags filters and offset/limit pagination (default limit 1000, max 1000) with deterministic ordering over the filtered set. `meta.categories` reports per-category counts within the caller's visible articles. Use `fields` to control the projection (default id,title,category; request tags/status/updated_at explicitly) to keep the payload small. Role: agent+.","operationId":"LoopctlWeb.KnowledgeIndexController.index","parameters":[{"description":"Project UUID (optional, for project-scoped index)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Filter by category (pattern, convention, decision, finding, reference). Returns 400 for an unknown category.","in":"query","name":"category","required":false,"schema":{"type":"string"}},{"description":"Comma-separated tags (match mode set by `match`, default ANY)","in":"query","name":"tags","required":false,"schema":{"type":"string"}},{"description":"Tag match mode: any (default, OR) or all (AND — carries every listed tag)","in":"query","name":"match","required":false,"schema":{"type":"string"}},{"description":"Filter to articles with this source_type (by-source enumeration)","in":"query","name":"source_type","required":false,"schema":{"type":"string"}},{"description":"Filter to articles with this source_id UUID (by-source enumeration). A malformed id matches nothing.","in":"query","name":"source_id","required":false,"schema":{"type":"string"}},{"description":"Max articles to return (default 1000, max 1000). A limit above the max is clamped to the maximum — never rejected — so pagination stays complete.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Articles to skip for pagination (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}},{"description":"Comma-separated projection (id, title, category, tags, status, updated_at). Default id,title,category. `id` and `category` are always included (category is the grouping key). Returns 400 for unknown fields.","in":"query","name":"fields","required":false,"schema":{"type":"string"}},{"description":"Opaque KEYSET cursor for drift-free enumeration of the index (US-27.9b). To use cursor pagination, pass an empty string (`cursor=`) on the FIRST request to opt into the keyset path (which orders by `inserted_at ASC, id ASC`); then follow `meta.next_cursor` verbatim on subsequent requests. Omitting the `cursor` parameter entirely uses the legacy offset path (orders by `category, updated_at DESC, id`), which does not emit `next_cursor`. Do not mix the two paths mid-enumeration, as the sort order differs. The keyset path honors the same category/tags/source filters and is the drift-free way to walk a tag or a source to exhaustion under concurrent writes. The cursor is integrity-protected and tenant-bound — a tampered/forged cursor is rejected with 400.","in":"query","name":"cursor","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Articles grouped by category","type":"object"},"meta":{"properties":{"categories":{"type":"object"},"fields":{"items":{"type":"string"},"type":"array"},"has_more":{"type":"boolean"},"limit":{"type":"integer"},"offset":{"type":"integer"},"total_count":{"type":"integer"},"truncated":{"type":"boolean"}},"type":"object"}},"type":"object"}}},"description":"Knowledge index"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Knowledge index","tags":["Knowledge Wiki"]}},"/api/v1/skills/{id}/stats":{"get":{"callbacks":{},"description":"Returns performance statistics for a skill.","operationId":"LoopctlWeb.SkillController.stats","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Skill stats"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get skill stats","tags":["Skills"]}},"/api/v1/memory/graduate":{"post":{"callbacks":{},"description":"Graduates ONE of the caller's own long-term memories (by `memory_id`) into a durable Knowledge Wiki article via the novelty gate — the explicit, on-demand trigger for the same primitive the hourly `MemoryGraduationSweepWorker` runs (#411 Gap 3). Scope (`tenant_id`, `subject_id`) is derived from the API key — a caller may only graduate its OWN memory; a foreign/nonexistent `memory_id` returns 404 (no cross-subject existence oracle). By DEFAULT the article inherits the memory's `project_id` (a project memory → a project article, a global memory → a global article); pass `re_scope: \"global\"` to promote a PROJECT-scoped memory to a tenant-wide (`project_id: null`) article — the ONLY way graduation re-scopes (the sweep never does). Because a memory has AT MOST ONE graduated article, `re_scope: \"global\"` on an ALREADY-graduated memory returns 409 (`already_graduated`) rather than silently returning the wrong-scoped article; re-scope on the FIRST graduation instead. The article is DEDUPED by the novelty gate: `verdict` is `created` (novel → published) or `gated_to_draft` (near-dup → unpublished draft) with 201, or `duplicate`/`deduplicated` (already represented → the canonical article, nothing created) with 200. A malformed `memory_id` is a 422 (`invalid_memory_id`); a `re_scope` outside the {`inherit`, `global`} enum is a 422 (`invalid_re_scope`) — it is NEVER silently treated as `inherit`. If the novelty gate falls open because the embedding backend is down it returns 503 (`gate_unavailable`) WITHOUT stamping — retry once embeddings recover. Subject to the full :authenticated write chain (custody halt, witness header, rate limiting).","operationId":"LoopctlWeb.MemoryController.graduate","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryGraduateRequest"}}},"description":"Graduate params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryGraduateResponse"}}},"description":"Graduated (dedup: content already represented by an existing article)"},"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MemoryGraduateResponse"}}},"description":"Graduated (a new published article or review draft was created)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Memory not found in the caller's own scope"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Memory already graduated (re_scope on an already-graduated memory)"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Malformed memory_id, out-of-enum re_scope, invalid structural content, or subject unresolvable"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Tenant custody halted (`custody_halted`) or novelty gate unavailable (`gate_unavailable`, embedding backend down — retryable); the body `code` distinguishes them"}},"summary":"Graduate (memory → durable knowledge article)","tags":["Agent Memory"]}},"/api/v1/admin/tenants/{id}":{"get":{"callbacks":{},"description":"Returns full tenant detail with all summary stats.","operationId":"LoopctlWeb.AdminTenantController.show","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Tenant detail"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get tenant detail (admin)","tags":["Admin"]},"patch":{"callbacks":{},"description":"Updates a tenant. Settings are partially merged.","operationId":"LoopctlWeb.AdminTenantController.update","parameters":[{"description":"Tenant UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Updated tenant"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update tenant (admin)","tags":["Admin"]}},"/api/v1/knowledge/progressive/{id}":{"get":{"callbacks":{},"description":"Fetches the full body of a single article a progressive index stub pointed at, scope-enforced exactly like a direct fetch. Resolves both tenant-owned articles and published system canonicals (the same set the index surfaces). Role: agent+.","operationId":"LoopctlWeb.KnowledgeProgressiveController.drill","parameters":[{"description":"Article UUID to drill into.","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"The full article, including body.","type":"object"}},"type":"object"}}},"description":"Full article"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Progressive-disclosure drill","tags":["Knowledge Wiki"]}},"/api/v1/skills/import":{"post":{"callbacks":{},"description":"Bulk import skills from an array.","operationId":"LoopctlWeb.SkillController.import_skills","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"skills":{"items":{"additionalProperties":true,"type":"object"},"type":"array"}},"required":["skills"],"type":"object"}}},"description":"Import params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Import summary"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Import skills","tags":["Skills"]}},"/api/v1/knowledge/ingest/batch":{"post":{"callbacks":{},"description":"Submit multiple URLs or raw content items for knowledge extraction in a single request. Each item is validated and enqueued independently. Max 50 items per batch. Role: orchestrator+.\n\n**Backpressure (429):** before enqueuing anything, the endpoint checks the calling tenant's in-flight `:ingestion` backlog. If it is at/over the `OBAN_INGEST_BACKLOG_MAX` threshold, the WHOLE request is rejected all-or-nothing with 429 + `Retry-After` and `error.code: \"ingestion_backlog_exceeded\"` — zero jobs are enqueued. This is distinct from the generic Hammer request-rate 429 (which has no `error.code`).","operationId":"LoopctlWeb.KnowledgeIngestionController.create_batch","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"items":{"description":"Array of ingestion items (max 50). Each item has the same shape as POST /knowledge/ingest: url or content, source_type (required), project_id (optional), publish (optional, default false → draft), metadata (optional).","maxItems":50,"type":"array"}},"required":["items"],"type":"object"}}},"description":"Batch ingestion request","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Per-item results, one entry per submitted item.","type":"array"}},"type":"object"}}},"description":"Batch ingestion results"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/IngestionBacklogError"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Too Many Requests — one of two DISTINCT 429s this route can return: (1) US-36.3 ingestion-backlog backpressure (`error.code: \"ingestion_backlog_exceeded\"`, sets `Retry-After`), or (2) the generic shared Hammer request-rate limiter (NO `error.code`; `error.message: \"Rate limit exceeded\"`). Branch on the presence of `error.code`."}},"summary":"Batch ingest content for knowledge extraction","tags":["Knowledge Wiki"]}},"/api/v1/projects/{project_id}/knowledge/export":{"get":{"callbacks":{},"description":"Streams published articles as an Obsidian-compatible gzipped tar archive. Files are organized as `{category}/{slug}-{short_id}.md` (the id suffix guarantees collision-free paths) with YAML frontmatter, [[wikilinks]], and a root `_index.md`. Only published articles are included. When called via GET /projects/:project_id/knowledge/export, includes both tenant-wide and project-specific articles. Bounded memory, no article-count cap, fail-closed on mid-stream error. Each article's related-link list is capped at export_max_links_per_article (default 100) per direction; a capped article carries `links_truncated: true` in its frontmatter. Role: user+.","operationId":"LoopctlWeb.KnowledgeExportController.export","parameters":[{"description":"Project UUID (optional, for project-scoped export)","in":"path","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/gzip":{"schema":{"format":"binary","type":"string"}}},"description":"Obsidian .tar.gz archive (chunked)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Too many concurrent exports"}},"summary":"Export knowledge as a streamed Obsidian .tar.gz","tags":["Knowledge Wiki"]}},"/api/v1/stories/{id}/history":{"get":{"callbacks":{},"description":"Returns the full audit trail for a specific story.","operationId":"LoopctlWeb.StoryHistoryController.show","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Story history"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get story history","tags":["Stories"]}},"/api/v1/stories/{id}/backfill":{"post":{"callbacks":{},"description":"Marks a story as verified for work completed outside loopctl (e.g. before onboarding). Only permitted for stories that never entered loopctl's dispatch lifecycle — stories with `assigned_agent_id` set, or already `:verified`/`:rejected`, are refused. Requires a non-empty `reason`; `evidence_url` and `pr_number` are optional but strongly recommended. Emits a `story.backfilled` webhook event on success.","operationId":"LoopctlWeb.StoryVerificationController.backfill","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"evidence_url":{"nullable":true,"type":"string"},"pr_number":{"nullable":true,"type":"integer"},"reason":{"type":"string"}},"required":["reason"],"type":"object"}}},"description":"Backfill params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story backfilled"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Insufficient role (orchestrator+ required)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Story not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error: missing reason, already verified, already rejected, or has dispatch lineage"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Backfill verified status","tags":["Progress"]}},"/api/v1/epic_dependencies/{id}":{"delete":{"callbacks":{},"description":"Removes an epic dependency edge.","operationId":"LoopctlWeb.EpicDependencyController.delete","parameters":[{"description":"Dependency UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Delete epic dependency","tags":["Dependencies"]}},"/api/v1/articles/{id}":{"delete":{"callbacks":{},"description":"Archives an article (soft delete — sets status: archived, retains the row; reversible and audited). Role: agent+ (an agent may only archive articles it can see — another agent's private/owner memory 404s).","operationId":"LoopctlWeb.ArticleController.delete","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Archived article"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Archive article","tags":["Knowledge Wiki"]},"get":{"callbacks":{},"description":"Returns article detail with outgoing and incoming links preloaded. Role: agent+.","operationId":"LoopctlWeb.ArticleController.show","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Article detail"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get article","tags":["Knowledge Wiki"]},"patch":{"callbacks":{},"description":"**PATCH /api/v1/articles/:id** — updates an existing article in place. Send only the fields to change; supported keys are `title`, `body`, `tags` (replaces the whole array), `category`, `metadata`, and `project_id`. Partial updates are supported (e.g. body-only to tidy a hub, or tags-only). `tenant_id` is never accepted from the body. Returns the full updated article. A changed `body`/`tags` re-triggers embedding/linking. Role: agent+ for content edits (KB content curation; an agent may only edit articles it can see — another agent's private/owner memory 404s). `status` is a **user+**-only key on PATCH (a `status` in the body from a lower role returns 403 `status_change_forbidden`); agents/orchestrators change lifecycle via the dedicated endpoints — POST /articles/:id/publish (orchestrator+), /unpublish (user+), /archive (agent+) — which enforce the transition guards.","operationId":"LoopctlWeb.ArticleController.update (2)","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Updated article"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden — a `status` change on PATCH requires role user+ (code status_change_forbidden); use the lifecycle endpoints instead"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update article","tags":["Knowledge Wiki"]},"put":{"callbacks":{},"description":"**PATCH /api/v1/articles/:id** — updates an existing article in place. Send only the fields to change; supported keys are `title`, `body`, `tags` (replaces the whole array), `category`, `metadata`, and `project_id`. Partial updates are supported (e.g. body-only to tidy a hub, or tags-only). `tenant_id` is never accepted from the body. Returns the full updated article. A changed `body`/`tags` re-triggers embedding/linking. Role: agent+ for content edits (KB content curation; an agent may only edit articles it can see — another agent's private/owner memory 404s). `status` is a **user+**-only key on PATCH (a `status` in the body from a lower role returns 403 `status_change_forbidden`); agents/orchestrators change lifecycle via the dedicated endpoints — POST /articles/:id/publish (orchestrator+), /unpublish (user+), /archive (agent+) — which enforce the transition guards.","operationId":"LoopctlWeb.ArticleController.update","parameters":[{"description":"Article UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Updated article"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Forbidden — a `status` change on PATCH requires role user+ (code status_change_forbidden); use the lifecycle endpoints instead"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update article","tags":["Knowledge Wiki"]}},"/api/v1/epics/{id}":{"delete":{"callbacks":{},"description":"Deletes an epic and cascades to stories. Requires user+ role.","operationId":"LoopctlWeb.EpicController.delete","parameters":[{"description":"Epic UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"Deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Delete epic","tags":["Epics"]},"get":{"callbacks":{},"description":"Returns epic detail with stories preloaded.","operationId":"LoopctlWeb.EpicController.show","parameters":[{"description":"Epic UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EpicResponse"}}},"description":"Epic detail"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get epic","tags":["Epics"]},"patch":{"callbacks":{},"description":"Updates an epic. Number cannot be changed. Requires orchestrator+ role.","operationId":"LoopctlWeb.EpicController.update","parameters":[{"description":"Epic UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EpicResponse"}}},"description":"Updated epic"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update epic","tags":["Epics"]}},"/api/v1/stories/{story_id}/verifications":{"get":{"callbacks":{},"description":"Lists verification results for a story with pagination.","operationId":"LoopctlWeb.StoryVerificationController.index","parameters":[{"description":"Story UUID","in":"path","name":"story_id","required":true,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/VerificationResultResponse"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Verification list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List verifications","tags":["Progress"]}},"/api/v1/tenants/me":{"get":{"callbacks":{},"description":"Returns the tenant profile for the authenticated API key.","operationId":"LoopctlWeb.TenantController.show","parameters":[],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantResponse"}}},"description":"Tenant profile"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get current tenant profile","tags":["Tenants"]},"patch":{"callbacks":{},"description":"Updates the tenant profile. Requires user+ role. `slug` is immutable post-creation (it keys the audit-key secret) and is not part of the request body — see rls-02.","operationId":"LoopctlWeb.TenantController.update","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantUpdateRequest"}}},"description":"Update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantResponse"}}},"description":"Updated tenant"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update current tenant profile","tags":["Tenants"]}},"/api/v1/knowledge/graph":{"get":{"callbacks":{},"description":"Walks the published article-link graph outward from `article_id` up to `depth` hops (1–3, default 1), respecting visibility (agent callers see only their own and `shared` articles). **Bidirectional** (links followed regardless of source/target direction) and **cycle-safe** (no node appears twice). Returns `nodes` (`id`, `title`, `category`, `depth`), `edges` (`source_article_id`, `target_article_id`, `relationship_type`), `truncated`, and `node_count`. Bounded to 100 nodes / 500 edges; `truncated: true` when a cap is hit. Only published articles are traversed; bounded to visible articles. Role: agent+.","operationId":"LoopctlWeb.KnowledgeGraphController.graph","parameters":[{"description":"Starting article UUID (required)","in":"query","name":"article_id","required":false,"schema":{"type":"string"}},{"description":"Hops to traverse (1–3, default 1)","in":"query","name":"depth","required":false,"schema":{"type":"integer"}},{"description":"Optional project UUID (attribution)","in":"query","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"edges":{"type":"array"},"node_count":{"type":"integer"},"nodes":{"type":"array"},"truncated":{"type":"boolean"}},"type":"object"}}},"description":"Graph"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad request"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Article not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Traverse the knowledge graph","tags":["Knowledge Wiki"]}},"/api/v1/webhooks/{id}/test":{"post":{"callbacks":{},"description":"Sends a test event to the webhook endpoint.","operationId":"LoopctlWeb.WebhookController.test","parameters":[{"description":"Webhook UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Test enqueued"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Test webhook","tags":["Webhooks"]}},"/api/v1/skills/{id}/cost-performance":{"get":{"callbacks":{},"description":"Returns cost metrics per skill version: invocations, total cost, average cost per story, verification rate, cost change vs previous version, and cost regression flag. Requires 'Token Efficiency' context: tagged under both Skills and Token Efficiency.","operationId":"LoopctlWeb.SkillController.cost_performance","parameters":[{"description":"Skill UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"description":"Cost metrics per skill version","items":{"properties":{"avg_cost_per_story_millicents":{"nullable":true,"type":"integer"},"cost_change_pct":{"description":"Percentage cost change vs previous version (nil for v1)","nullable":true,"type":"number"},"cost_regression":{"description":"True if this version costs more than the previous version","type":"boolean"},"invocations":{"description":"Number of stories that used this version","type":"integer"},"total_cost_millicents":{"type":"integer"},"verification_rate":{"description":"Fraction of stories verified (0.0 to 1.0)","nullable":true,"type":"number"},"version":{"description":"Skill version number","type":"integer"}},"type":"object"},"type":"array"}},"type":"object"}}},"description":"Cost performance"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get skill cost performance","tags":["Skills"]}},"/api/v1/token-budgets/{id}":{"delete":{"callbacks":{},"description":"Deletes a token budget. Does not delete associated token usage reports.","operationId":"LoopctlWeb.TokenBudgetController.delete","parameters":[{"description":"Budget UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"description":"Confirmation of deletion","properties":{"token_budget":{"properties":{"deleted":{"example":true,"type":"boolean"},"id":{"format":"uuid","type":"string"}},"type":"object"}},"type":"object"}}},"description":"Budget deleted"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Budget not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Delete token budget","tags":["Token Efficiency"]},"get":{"callbacks":{},"description":"Returns a single token budget with current spend and remaining calculated in real-time.","operationId":"LoopctlWeb.TokenBudgetController.show","parameters":[{"description":"Budget UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"token_budget":{"$ref":"#/components/schemas/TokenBudget"}},"type":"object"}}},"description":"Budget details"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Budget not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Get token budget","tags":["Token Efficiency"]},"patch":{"callbacks":{},"description":"Updates budget_millicents, token limits, or alert_threshold_pct. Cannot change scope_type or scope_id.","operationId":"LoopctlWeb.TokenBudgetController.update (2)","parameters":[{"description":"Budget UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"alert_threshold_pct":{"maximum":100,"minimum":1,"type":"integer"},"budget_input_tokens":{"minimum":0,"nullable":true,"type":"integer"},"budget_millicents":{"minimum":1,"type":"integer"},"budget_output_tokens":{"minimum":0,"nullable":true,"type":"integer"},"metadata":{"additionalProperties":true,"type":"object"}},"type":"object"}}},"description":"Token budget update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"token_budget":{"$ref":"#/components/schemas/TokenBudget"}},"type":"object"}}},"description":"Budget updated"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Budget not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update token budget","tags":["Token Efficiency"]},"put":{"callbacks":{},"description":"Updates budget_millicents, token limits, or alert_threshold_pct. Cannot change scope_type or scope_id.","operationId":"LoopctlWeb.TokenBudgetController.update","parameters":[{"description":"Budget UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"properties":{"alert_threshold_pct":{"maximum":100,"minimum":1,"type":"integer"},"budget_input_tokens":{"minimum":0,"nullable":true,"type":"integer"},"budget_millicents":{"minimum":1,"type":"integer"},"budget_output_tokens":{"minimum":0,"nullable":true,"type":"integer"},"metadata":{"additionalProperties":true,"type":"object"}},"type":"object"}}},"description":"Token budget update params","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"token_budget":{"$ref":"#/components/schemas/TokenBudget"}},"type":"object"}}},"description":"Budget updated"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Budget not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Update token budget","tags":["Token Efficiency"]}},"/api/v1/stories/{id}/artifacts":{"get":{"callbacks":{},"description":"Lists all artifact reports for a story with pagination.","operationId":"LoopctlWeb.ArtifactReportController.index","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Artifact list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List artifact reports","tags":["Artifacts"]},"post":{"callbacks":{},"description":"Submits an artifact report for a story.","operationId":"LoopctlWeb.ArtifactReportController.create","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ArtifactReportRequest"}}},"description":"Artifact params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Artifact created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Story not found"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Submit artifact report","tags":["Artifacts"]}},"/api/v1/articles/{article_id}/links":{"get":{"callbacks":{},"description":"Returns all links (outgoing and incoming) for an article, with linked articles preloaded. Role: agent+.","operationId":"LoopctlWeb.ArticleLinkController.index","parameters":[{"description":"Article UUID","in":"path","name":"article_id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Link list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List links for article","tags":["Knowledge Wiki"]}},"/api/v1/stories/{id}/force-unclaim":{"post":{"callbacks":{},"description":"Orchestrator force-unclaims a story, resetting it to pending.","operationId":"LoopctlWeb.StoryVerificationController.force_unclaim","parameters":[{"description":"Story UUID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoryStatusResponse"}}},"description":"Story unclaimed"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Force unclaim story","tags":["Progress"]}},"/api/v1/knowledge/ingest":{"post":{"callbacks":{},"description":"Submit a URL or raw content for knowledge extraction. Enqueues an Oban job that fetches the content (if URL), extracts knowledge articles via LLM, and inserts them. Extracted articles are created as **drafts by default** (lower-trust LLM output, staged for review) — unlike direct POST /articles which publishes by default. Pass `publish: true` to publish them on extraction instead. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeIngestionController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"content":{"description":"Raw content to extract from (exactly one of url or content required)","type":"string"},"metadata":{"description":"Optional metadata map","type":"object"},"project_id":{"description":"Optional project UUID to scope extracted articles","type":"string"},"publish":{"description":"Publish extracted articles immediately instead of staging them as drafts. Default false (draft).","type":"boolean"},"source_type":{"description":"Source type (e.g., newsletter, skill, web_article, ingestion). Required.","type":"string"},"url":{"description":"URL to fetch content from (exactly one of url or content required)","type":"string"}},"required":["source_type"],"type":"object"}}},"description":"Ingestion request","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"properties":{"content_hash":{"type":"string"},"status":{"type":"string"}},"type":"object"}},"type":"object"}}},"description":"Already queued"},"202":{"content":{"application/json":{"schema":{"properties":{"data":{"properties":{"content_hash":{"type":"string"},"id":{"type":"integer"},"inserted_at":{"type":"string"},"source_type":{"type":"string"},"status":{"type":"string"}},"type":"object"}},"type":"object"}}},"description":"Ingestion job queued"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/IngestionBacklogError"},{"$ref":"#/components/schemas/RateLimitError"}]}}},"description":"Too Many Requests — one of two DISTINCT 429s this route can return: (1) US-36.3 ingestion-backlog backpressure (`error.code: \"ingestion_backlog_exceeded\"`, sets `Retry-After`) — the single-item path is gated on the SAME per-tenant backlog threshold as /ingest/batch so it cannot be looped to bypass the valve — or (2) the generic shared Hammer request-rate limiter (NO `error.code`). Branch on the presence of `error.code`."}},"summary":"Ingest content for knowledge extraction","tags":["Knowledge Wiki"]}},"/api/v1/knowledge/analytics/retrieval-metrics":{"get":{"callbacks":{},"description":"Daily retrieval PRECISION (agents' KB #3): the share of a day's search results the agent then opened (search → get/context within a window). A proxy for retrieval quality that trends up as the corpus is de-duplicated and better navigated. Most recent day first. Role: orchestrator+.","operationId":"LoopctlWeb.KnowledgeAnalyticsController.retrieval_metrics","parameters":[{"description":"Days per page (default 30, max 365). Clamped, never rejected.","in":"query","name":"limit","required":false,"schema":{"type":"integer"}},{"description":"Days to skip (default 0)","in":"query","name":"offset","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"Retrieval metrics"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Retrieval precision time series","tags":["Knowledge Analytics"]}},"/api/v1/egress/local-only":{"delete":{"callbacks":{},"description":"WIDENS the posture, so it is role :user ONLY — an agent or orchestrator key must never be able to re-open egress one tool call before a harvest. Audited.","operationId":"LoopctlWeb.EgressController.clear_local_only","parameters":[{"description":"Project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"local_only cleared"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Insufficient role"}},"summary":"Clear local_only for a scope","tags":["Egress"]},"post":{"callbacks":{},"description":"TIGHTENS the posture (role :orchestrator+). Runs the MANDATORY PRE-FLIGHT: if any currently-resolved endpoint would become egress_blocked, responds 409 would_block_endpoints NAMING each offending endpoint; retry with acknowledge=true to proceed, and the response then REPORTS the resulting blocked posture.","operationId":"LoopctlWeb.EgressController.enable_local_only","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"local_only enable request","required":false},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":true,"type":"object"}}},"description":"local_only enabled"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Insufficient role"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Would block resolved endpoints"}},"summary":"Enable local_only for a scope","tags":["Egress"]}},"/api/v1/token-budgets":{"get":{"callbacks":{},"description":"Returns all token budgets for the tenant. Filterable by scope_type and scope_id. Includes current spend and remaining budget for each entry.","operationId":"LoopctlWeb.TokenBudgetController.index","parameters":[{"description":"Filter by scope type: project, epic, story","in":"query","name":"scope_type","required":false,"schema":{"type":"string"}},{"description":"Filter by scope UUID","in":"query","name":"scope_id","required":false,"schema":{"type":"string"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/TokenBudget"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Budget list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List token budgets","tags":["Token Efficiency"]},"post":{"callbacks":{},"description":"Creates a budget for a project, epic, or story scope. Only one budget per (scope_type, scope_id) pair is allowed.","operationId":"LoopctlWeb.TokenBudgetController.create","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"properties":{"alert_threshold_pct":{"default":80,"maximum":100,"minimum":1,"type":"integer"},"budget_input_tokens":{"minimum":0,"nullable":true,"type":"integer"},"budget_millicents":{"minimum":1,"type":"integer"},"budget_output_tokens":{"minimum":0,"nullable":true,"type":"integer"},"metadata":{"additionalProperties":true,"type":"object"},"scope_id":{"format":"uuid","type":"string"},"scope_type":{"enum":["project","epic","story"],"type":"string"}},"required":["scope_type","scope_id","budget_millicents"],"type":"object"}}},"description":"Token budget params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"properties":{"token_budget":{"$ref":"#/components/schemas/TokenBudget"}},"type":"object"}}},"description":"Budget created"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Scope entity not found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Budget already exists for scope"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create token budget","tags":["Token Efficiency"]}},"/api/v1/projects/{project_id}/knowledge/okf/export":{"get":{"callbacks":{},"description":"Exports published articles as an OKF v0.1 bundle. Defaults to a streamed gzipped tar archive (bounded memory, no article-count cap, fail-closed on mid-stream error); pass format=json for a `{files, meta}` JSON payload (buffered in memory — for tooling that writes the files itself — and so capped at export_max_buffered_export_articles, 413 over it). Each concept's `# Related` list is capped at export_max_links_per_article (default 100) per direction; a capped concept carries `loopctl_links_truncated: true` in frontmatter. When called via GET /projects/:project_id/knowledge/okf/export, includes tenant-wide + project articles. Role: user+.","operationId":"LoopctlWeb.OKFController.export","parameters":[{"description":"","in":"path","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"tar.gz (default) or json","in":"query","name":"format","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/gzip":{"schema":{"format":"binary","type":"string"}}},"description":"OKF bundle (.tar.gz, chunked)"},"413":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"format=json bundle exceeds the buffered-export cap (use the streamed .tar.gz)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Too many concurrent exports"}},"summary":"Export knowledge as an OKF bundle (streamed .tar.gz)","tags":["Knowledge Wiki"]}},"/api/v1/kb-scopes":{"post":{"callbacks":{},"description":"Creates a knowledge-only project scope (kind: kb). Available at agent+ role and NOT gated by the human-anchor tier, because a kb scope is structurally barred from the work-breakdown / chain-of-custody surface. Use it to partition knowledge articles by repo on the agent-rooted KB tier.","operationId":"LoopctlWeb.ProjectController.create_kb_scope","parameters":[],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectCreateRequest"}}},"description":"KB scope params","required":false},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}},"description":"KB scope created"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Validation error or project limit reached"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"Create knowledge-only project scope","tags":["Projects"]}},"/api/v1/cost-anomalies":{"get":{"callbacks":{},"description":"Returns unresolved cost anomalies for the tenant. Filterable by anomaly_type and project_id. Includes story title and agent name. Archived anomalies are excluded by default; use ?include_archived=true to include them.","operationId":"LoopctlWeb.CostAnomalyController.index","parameters":[{"description":"Filter by anomaly type: high_cost, suspiciously_low, budget_exceeded","in":"query","name":"anomaly_type","required":false,"schema":{"type":"string"}},{"description":"Filter by project UUID","in":"query","name":"project_id","required":false,"schema":{"type":"string"}},{"description":"Include archived anomalies (default: false)","in":"query","name":"include_archived","required":false,"schema":{"type":"boolean"}},{"description":"Filter by resolved status. true = resolved only, false = unresolved only (default: false)","in":"query","name":"resolved","required":false,"schema":{"type":"boolean"}},{"description":"Page number","in":"query","name":"page","required":false,"schema":{"type":"integer"}},{"description":"Items per page","in":"query","name":"page_size","required":false,"schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"data":{"items":{"$ref":"#/components/schemas/CostAnomaly"},"type":"array"},"meta":{"$ref":"#/components/schemas/PaginationMeta"}},"type":"object"}}},"description":"Anomaly list"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RateLimitError"}}},"description":"Rate limit exceeded"}},"summary":"List cost anomalies","tags":["Token Efficiency"]}}},"security":[{"BearerAuth":[]}],"servers":[{"description":"Current server","url":"/","variables":{}},{"description":"Local development","url":"http://localhost:4030","variables":{}}],"tags":[{"description":"API discovery and health","name":"Discovery"},{"description":"Health check endpoints","name":"Health"},{"description":"Tenant registration and management","name":"Tenants"},{"description":"API key management","name":"Auth"},{"description":"Project CRUD","name":"Projects"},{"description":"Epic CRUD within projects","name":"Epics"},{"description":"Story CRUD within epics","name":"Stories"},{"description":"Two-tier story status management (contract/claim/verify)","name":"Progress"},{"description":"Epic and story dependency management with cycle detection","name":"Dependencies"},{"description":"Artifact reports and verification results","name":"Artifacts"},{"description":"Agent registration and listing","name":"Agents"},{"description":"Orchestrator state checkpointing","name":"Orchestrator"},{"description":"Webhook subscriptions and delivery","name":"Webhooks"},{"description":"Bulk import/export of project data","name":"Import/Export"},{"description":"Skill versioning and performance tracking","name":"Skills"},{"description":"Superadmin tenant management and system stats","name":"Admin"},{"description":"Immutable audit log and change feed","name":"Audit"},{"description":"Token usage reporting, budget configuration, cost anomaly management, and analytics. Includes per-agent, per-epic, per-project, per-model, trend, and model-mix analytics. Webhook events: token.budget_warning, token.budget_exceeded, token.anomaly_detected.","name":"Token Efficiency"}]}