{"openapi":"3.1.0","info":{"title":"Mnemo API","version":"0.0.0"},"paths":{"/v1/auth/register":{"post":{"tags":["auth"],"summary":"Self-service registration — create user, tenant, and first API key.","description":"Atomic registration: user + tenant + api_key in one transaction.\n\nReturns JWT access token and the plaintext api_key (shown ONCE).\nAudit-trail fields (signed_ip, signed_user_agent) come from request\nheaders, not from the request body.\n\nCross-field validation (consents, password match) is performed here\nrather than in Pydantic model_validator to emit ADR-014 wire format\n{\"detail\": {\"error\": \"...\", ...}} instead of FastAPI's default 422 shape.","operationId":"register_v1_auth_register_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterResponse"}}}},"409":{"description":"Email already registered"},"422":{"description":"Validation error (missing consents, weak password, invalid email)"},"429":{"description":"Rate limit: 10 req/min per IP (middleware), or the per-day registration limit per client address / network (error=registration_limit_exceeded, with Retry-After)."}}}},"/v1/auth/login":{"post":{"tags":["auth"],"summary":"Login with email + password, receive JWT access token.","description":"Verify email + argon2id password, issue JWT HS256 access token.\n\nReturns constant-time 401 for both wrong email and wrong password\nto prevent user enumeration.","operationId":"login_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"401":{"description":"Invalid credentials"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/auth/me":{"get":{"tags":["auth"],"summary":"Return authenticated user info, tenant, and consent status.","description":"Decode JWT from Authorization: Bearer header and return user + tenant.\n\nJWT errors are mapped to ADR-014 error codes (token_expired, invalid_token).","operationId":"me_v1_auth_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MeResponse"}}}},"401":{"description":"Missing, expired, or invalid JWT"}}}},"/v1/auth/logout":{"post":{"tags":["auth"],"summary":"Revoke the current JWT access token (server-side denylist).","description":"Add the current JWT jti to the Redis denylist.\n\nCallers must pass a valid JWT in Authorization: Bearer header.\nOn success (HTTP 200) the token is invalidated immediately — subsequent\nrequests using the same token will receive 401 token_revoked.\n\nFail-closed: if Redis is unavailable the endpoint returns 503 (unlike\nis_revoked which is fail-open). A failed revocation is a security regression\nso we surface the error rather than silently skip it (per token_denylist.py\ndocstring).\n\nADR-019 prefix dispatch: this endpoint is JWT-only. Requests with an API key\nBearer value (adsh_* / mne_*) receive 401 invalid_token — API keys have\ntheir own revocation path (DELETE /v1/api-keys/{id}).","operationId":"logout_v1_auth_logout_post","responses":{"200":{"description":"Token revoked (or already expired — idempotent)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LogoutResponse"}}}},"401":{"description":"Missing, expired, or invalid JWT"},"503":{"description":"Redis unavailable — revocation could not be persisted"}}}},"/v1/auth/verify-email":{"post":{"tags":["auth"],"summary":"Verify email address using the one-time token sent on registration.","description":"Consume a plaintext verification token and mark the user's email as verified.\n\nAtomic implementation (M2 fix): uses a single UPDATE … RETURNING statement\nto prevent race conditions on concurrent double-verify requests. Exactly one\ncall wins; subsequent calls with the same token receive 409.\n\nRate-limited to 10 req/min per IP via _AUTH_STRICT_PATHS (C2 spec).\nGET variant with /{token} path NOT included in W2 — only POST here.\nFrontend POSTs on load of /verify-email?token=… (Lane D4 post-C2 merge).\n\nError codes (ADR-014 wire format {\"detail\": {\"error\": \"...\"}}):\n  404 token_not_found   — hash not in table.\n  410 token_expired     — token found but past expires_at.\n  409 token_already_used — token was already consumed.\n  422 validation_failed — token field missing or too long.","operationId":"verify_email_v1_auth_verify_email_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyEmailRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyEmailResponse"}}}},"404":{"description":"Token not found"},"409":{"description":"Token already used"},"410":{"description":"Token expired"},"422":{"description":"Validation error"}}}},"/v1/auth/resend-verification":{"post":{"tags":["auth"],"summary":"Resend email verification link (public, anti-enumeration).","description":"Always returns 200 with an identical body regardless of whether the email is registered or already verified (STRIDE S1/S2 anti-enumeration). Rate-limited: 10 req/min per IP (Tier 1) + 3 req/hour per email (Redis).","operationId":"resend_verification_v1_auth_resend_verification_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResendVerificationRequest"}}},"required":true},"responses":{"200":{"description":"Uniform response — does not reveal email existence or status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResendVerificationResponse"}}}},"429":{"description":"Rate limit exceeded"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/auth/profile":{"get":{"tags":["auth"],"summary":"Return the caller's identity + tenant info (for the UI banner).","description":"Narrow profile shape for the Dashboard email-verification banner. Accepts either a dashboard session token or an API key (Bearer adsh_* / mne_*) — the session token is the production path (the Dashboard always sends one); API-key auth exists for tooling and tests. Distinct from /me in response shape, not in auth method. Returns whoever asked, not the workspace owner — a workspace can contain several people with different roles.","operationId":"profile_v1_auth_profile_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileResponse"}}}},"401":{"description":"Invalid or missing API key"}}},"delete":{"tags":["auth"],"summary":"Leave the workspace (ФЗ-152 §14 right-to-be-forgotten).","description":"Soft-deletes the authenticated user. PII columns (email, password_hash) are wiped immediately. The workspace (tenant) survives if any other person in it is still alive — only the caller's own API keys are revoked in that case. If the caller was the workspace's last living person, the whole workspace is soft-deleted with them (response field workspace_deleted=true), same as before Task A4. An administrator with other living members but no other administrator is refused with 409 last_admin_cannot_leave (Task A4, docs/technical/team-mode-a4-contract-2026-09-01.md §2.2) — pass the admin role to someone else first, or remove the other members. Deletion is immediate and irreversible: there is no cooling-off period (CEO 2026-09-01). JWT-only: API-key auth returns 401 (per STRIDE A1 — keys are revoked as part of the deletion, so they cannot be the auth mechanism here). To erase the ENTIRE workspace regardless of who else is in it, use DELETE /v1/workspace (admin only).","operationId":"delete_profile_v1_auth_profile_delete","responses":{"200":{"description":"Account soft-deleted — PII wiped, sessions revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteProfileResponse"}}}},"401":{"description":"Missing/expired/invalid JWT, API-key auth attempted (jwt_required), or the token's user/tenant pair does not resolve (user_tenant_mismatch)."},"409":{"description":"last_admin_cannot_leave — caller is the only administrator and other members remain. Nothing was changed."},"410":{"description":"Account already deleted (account_deleted) — token is for a ghost account."},"429":{"description":"Rate limit exceeded (Tier 1 — 10 req/min per IP)."}}}},"/v1/auth/password-reset/request":{"post":{"tags":["auth"],"summary":"Request a password reset link by email.","description":"Always returns 200 with an identical body regardless of whether the email is registered (AC #1/#3 — anti-enumeration, STRIDE S1/S2). Rate-limited: 10 req/min per IP (Tier 1) + 3 req/hour per email (Redis).","operationId":"password_reset_request_v1_auth_password_reset_request_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetRequestRequest"}}},"required":true},"responses":{"200":{"description":"Uniform response — does not reveal email existence","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetRequestResponse"}}}},"429":{"description":"Rate limit exceeded"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/auth/password-reset/confirm":{"post":{"tags":["auth"],"summary":"Confirm password reset with token and new password.","description":"Validates the reset token, updates the password (argon2id), bumps password_changed_at to invalidate all existing JWT sessions (ADR-047), and revokes all active API keys of the tenant (STRIDE E1). Token is single-use and expires in 60 minutes.","operationId":"password_reset_confirm_v1_auth_password_reset_confirm_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetConfirmRequest"}}},"required":true},"responses":{"200":{"description":"Password reset successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PasswordResetConfirmResponse"}}}},"400":{"description":"Token expired, used, or invalid"},"422":{"description":"Password too weak or token missing/too long"},"429":{"description":"Rate limit exceeded"}}}},"/v1/api-keys":{"get":{"tags":["api-keys"],"summary":"List API keys for the authenticated tenant (no plaintext or hash).","description":"Return all API keys (active and revoked) for the authenticated tenant.\n\nkey_hash and plaintext are NEVER present in the response (AC #4, STRIDE I1/I3).\nRLS on the tenant session automatically scopes results to the current tenant;\nthe explicit WHERE clause below is defence-in-depth against IDOR (AC #8).\n\nAlso reports active_key_count / active_key_limit (FREE-TIER-TIGHTEN AC #5)\nso the Dashboard can render \"used N of M\" without hardcoding tier numbers.\nA tenant row that cannot be resolved (edge case: deleted mid-request)\nfalls back to the default (most restrictive) limit rather than failing\nthe whole list request.","operationId":"list_api_keys_v1_api_keys_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyListResponse"}}}},"401":{"description":"Not authenticated or API-key auth used (JWT required)"}}},"post":{"tags":["api-keys"],"summary":"Create a new API key.  Plaintext returned exactly once.","description":"Issue a new API key for the authenticated tenant.\n\nThe plaintext key is returned in this response EXACTLY ONCE (AC #1).\nAfter this call the plaintext is irrecoverable — the database stores\nonly the bcrypt hash (STRIDE I1/I3).\n\nScopes default to [\"read\", \"write\"] when not supplied — parity with the\nonboarding key (AC #3).  Pass scopes=[\"read\"] explicitly for a read-only key.\n\nRaises 412 when the tenant's tier is empty or unknown (ADR-083 amendment 1;\nfail closed, no key is created).\nRaises 422 api_key_limit_reached when the tenant's active key count\nreaches the tier limit (AC #10).","operationId":"create_api_key_v1_api_keys_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreateRequest"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiKeyCreateResponse"}}}},"401":{"description":"Not authenticated or API-key auth used (JWT required)"},"412":{"description":"The workspace account is closed for issuing keys (empty or unknown tier). No key is created. Body: machine code + short message, no tier, no tenant id."},"422":{"description":"api_key_limit_reached — tier limit of active keys exceeded"}}}},"/v1/api-keys/{api_key_id}":{"delete":{"tags":["api-keys"],"summary":"Revoke an API key (soft-delete, idempotent).","description":"Soft-delete an API key by setting revoked_at = NOW().\n\nIdempotent (AC #7): already-revoked key returns 200 (not 500).\nIDOR guard (AC #9): a key from another tenant returns 404 — same as\na non-existent key.  The caller learns nothing about other tenants.\nImmediate effect (AC #6): APIKeyService.verify() filters WHERE revoked_at IS NULL,\nso the next auth attempt with the revoked key returns 401.","operationId":"revoke_api_key_v1_api_keys__api_key_id__delete","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Api Key Id"}}],"responses":{"204":{"description":"Key revoked successfully"},"200":{"description":"Key was already revoked — idempotent no-op"},"401":{"description":"Not authenticated or API-key auth used (JWT required)"},"404":{"description":"Key not found or belongs to another tenant (IDOR-safe)"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/health":{"get":{"tags":["health"],"summary":"Basic health check","description":"Returns 200 if the API process is running and critical dependencies (postgres, redis) are reachable.  Returns 503 if any critical dependency is down.  No authentication required.","operationId":"health_v1_health_get","responses":{"200":{"description":"Basic health status with service version.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/v1/health/detailed":{"get":{"tags":["health"],"summary":"Detailed health check","description":"Returns per-component health status for postgres, redis, LLM provider and task workers.  All component checks run in parallel with a 300 ms timeout each.  Returns 503 if any critical component is down, 200 for healthy or degraded states.  No authentication required.","operationId":"health_detailed_v1_health_detailed_get","responses":{"200":{"description":"Detailed health status with per-component latency.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DetailedHealthResponse"}}}}}}},"/v1/whoami":{"get":{"tags":["debug"],"summary":"Return the authenticated caller's TenantContext (debug).","description":"Debug endpoint — will be removed before public beta (Sprint 4). Callers must not depend on this in production code paths.","operationId":"whoami_v1_whoami_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhoamiResponse"}}}}},"deprecated":true}},"/v1/documents":{"post":{"tags":["documents"],"summary":"Enqueue a document for the extraction pipeline.","description":"Creates a ``Document`` row with ``status='queued'`` and returns the ``document_id`` together with a polling URL.  The actual extraction worker (Taskiq) is wired in US-5 (E5.3).\n\n**Limits:** content must be non-empty and ≤ 1 000 000 bytes (UTF-8). Documents above 15 000 words are split into 4 parts (XL path) while ANDISH_XL_SPLIT_ENABLED is on (the default since ADR-037 A2); with the flag off (kill switch) they get 413 ``payload_too_large_xl_unavailable`` (detail: ``max_words``, ``words``). Hard cap: 50 000 words → 413 ``payload_too_large_xl_cap`` (detail includes ``words`` — actual word count — and ``parts_hint`` — suggested number of parts). Splitter failure on degenerate input → 422 ``xl_split_failed``.","operationId":"create_document_v1_documents_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentCreateRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentCreateResponse"}}}},"402":{"description":"``cost_cap_exceeded`` — the tenant's own 24-hour spend already reached its tier ceiling (body: ``error``, ``current_spend``, ``cap``, plus ``msg`` — a plain-language sentence an editor model can relay; no sums in it); ``cost_cap_monthly_exceeded`` — the calendar-month ceiling of the free tier is used up (same body plus ``retry_after`` seconds and the ``Retry-After`` header: the allowance comes back on the 1st at 00:00 UTC; when both ceilings are used up this one is returned); or ``platform_budget_paused`` — the platform-wide daily budget is used up (``Retry-After`` header)."},"413":{"description":"The document is refused before anything is stored. Detail ``error`` is one of: ``file_size_exceeded`` (tier file cap; ``max_mb``, ``size_mb``), ``payload_too_large`` (global byte guard; ``max_bytes``), ``payload_too_large_xl_cap`` (more than 50 000 words; ``max_words``, ``words``, ``parts_hint``), ``payload_too_large_xl_unavailable`` (between 15 000 and 50 000 words while the ANDISH_XL_SPLIT_ENABLED kill switch is off — large documents are temporarily not accepted, split the text to at most ``max_words`` words per upload; detail: ``max_words``, ``words``), ``storage_cap_exceeded`` or ``documents_cap_exceeded``."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["documents"],"summary":"List documents for the current tenant (paginated).","description":"Returns a paginated list of documents for the authenticated workspace.\n\n**Sorting:** Use ``sort=last_queried_at`` with ``order=asc`` (NULLs first, then oldest) or ``order=desc`` to sort by last citation timestamp.\n\n**Filtering by activity (Feature 03-bis):**\n- ``filter=never_queried`` — documents with ``last_queried_at IS NULL``\n- ``filter=quiet_90d`` — not cited in 90+ days (or never)\n- ``filter=quiet_30d`` — not cited in 30+ days (or never)\n- ``filter=active`` — cited within the last 30 days\n\nXL part rows are always hidden (ADR-037 §2.8).","operationId":"list_documents_v1_documents_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"sort","in":"query","required":false,"schema":{"anyOf":[{"enum":["created_at","last_queried_at"],"type":"string"},{"type":"null"}],"description":"Sort field. Default: created_at.","title":"Sort"},"description":"Sort field. Default: created_at."},{"name":"order","in":"query","required":false,"schema":{"anyOf":[{"enum":["asc","desc"],"type":"string"},{"type":"null"}],"description":"Sort direction. Default: desc for created_at, asc for last_queried_at.","title":"Order"},"description":"Sort direction. Default: desc for created_at, asc for last_queried_at."},{"name":"filter","in":"query","required":false,"schema":{"anyOf":[{"enum":["never_queried","quiet_90d","quiet_30d","active"],"type":"string"},{"type":"null"}],"description":"Activity filter. never_queried=never cited; quiet_90d=not cited in 90+ days; quiet_30d=not cited in 30+ days; active=cited within 30 days.","title":"Filter"},"description":"Activity filter. never_queried=never cited; quiet_90d=not cited in 90+ days; quiet_30d=not cited in 30+ days; active=cited within 30 days."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/documents/{document_id}":{"get":{"tags":["documents"],"summary":"Fetch a document's metadata by ID.","description":"Returns metadata for a single document.\n\n**include_impact=true:** Adds a ``deletion_impact`` block showing how many chunks, edges, entities, and vectors would be removed if this document were deleted. Requires the existing RLS-scoped session — no extra DB connection.\n\n**Default (include_impact=false):** Backward-compatible with v1 clients; ``deletion_impact`` is omitted from the response.","operationId":"get_document_v1_documents__document_id__get","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Document UUID.","title":"Document Id"},"description":"Document UUID."},{"name":"include_impact","in":"query","required":false,"schema":{"type":"boolean","description":"When true, includes a ``deletion_impact`` block in the response showing what would be affected by deleting this document.","default":false,"title":"Include Impact"},"description":"When true, includes a ``deletion_impact`` block in the response showing what would be affected by deleting this document."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["documents"],"summary":"Delete a document (requires explicit confirmation).","description":"Deletes a document from the tenant's knowledge graph.\n\n**Without confirm=true (default):** Returns 409 Conflict with a ``deletion_impact`` preview so the caller can make an informed decision about orphaned entities.\n\n**With confirm=true:** Runs the cascade (chunks → relations → optionally exclusive entities → vectors → document row) inside a single RLS-scoped transaction and returns 204 No Content with an ``X-Deletion-Impact`` header carrying the pre-deletion counters (chunks, edges, entities_removed, entities_preserved).\n\nCross-tenant: returns 404 uniformly (RLS-transparent, no tenant leak).","operationId":"delete_document_v1_documents__document_id__delete","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Document UUID.","title":"Document Id"},"description":"Document UUID."},{"name":"confirm","in":"query","required":false,"schema":{"type":"boolean","description":"Must be true to proceed with deletion. Omit (or false) to receive a 409 with deletion impact preview.","default":false,"title":"Confirm"},"description":"Must be true to proceed with deletion. Omit (or false) to receive a 409 with deletion impact preview."},{"name":"remove_orphans","in":"query","required":false,"schema":{"type":"boolean","description":"When confirm=true: if true (default), exclusive-to-this-doc entities are deleted along with their graph nodes and edges; if false, VDB entities are preserved as orphans (graph nodes/edges are still cleaned regardless, as they always become stale after document deletion).","default":true,"title":"Remove Orphans"},"description":"When confirm=true: if true (default), exclusive-to-this-doc entities are deleted along with their graph nodes and edges; if false, VDB entities are preserved as orphans (graph nodes/edges are still cleaned regardless, as they always become stale after document deletion)."}],"responses":{"204":{"description":"Deletion complete; X-Deletion-Impact header carries the pre-cascade counters."},"409":{"description":"Confirmation required — impact preview returned."},"404":{"description":"Document not found (or belongs to another tenant)."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/documents/{document_id}/entities":{"get":{"tags":["documents"],"summary":"Return the top entities extracted from a single document.","description":"Returns a flat list of this document's entities (name, type, degree), ordered by connectivity DESC — the same ranking ``GET /v1/graph`` uses for its top-N view.\n\n**Why this endpoint exists:** ``GET /v1/graph`` returns workspace-wide entities with no per-document link; using it to build per-document prompts leaked entities from other documents. This endpoint resolves the document -> chunks -> entities -> graph-node chain server-side.\n\n**Isolation:** 404 is returned uniformly for a missing OR cross-tenant ``document_id`` (no existence leak).\n\n**Ceiling:** ``limit`` is capped server-side; values above the ceiling are rejected (422), never silently clamped.","operationId":"get_document_entities_v1_documents__document_id__entities_get","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Document UUID.","title":"Document Id"},"description":"Document UUID."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"description":"Maximum number of entities to return. Default 10, server ceiling 50.","default":10,"title":"Limit"},"description":"Maximum number of entities to return. Default 10, server ceiling 50."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentEntitiesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/admin/documents/{parent_id}/parts":{"get":{"tags":["admin"],"summary":"List internal XL part records for a parent document (admin only).","description":"Returns per-part status for an XL parent document. Requires ``admin`` scope. Never returns content or content_encrypted. 404 if parent_id does not exist or is not an XL parent. 403 if caller lacks admin scope.","operationId":"get_document_parts_v1_admin_documents__parent_id__parts_get","parameters":[{"name":"parent_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"UUID of the XL parent document.","title":"Parent Id"},"description":"UUID of the XL parent document."}],"responses":{"200":{"description":"List of part records.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartsListResponse"}}}},"403":{"description":"Insufficient scope — admin required."},"404":{"description":"Parent document not found, not an XL parent, or belongs to another tenant."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/query":{"post":{"tags":["query"],"summary":"Query the tenant's knowledge graph.","description":"Run a natural-language query against the tenant's knowledge graph.\n\n**Who answers (ADR-082):** a JWT (dashboard session) request gets a synthesised answer (DeepSeek V3 via OpenRouter) plus best-effort source references. An **API-key request never calls our model at all** — the server returns retrieved material instead (see ``context_only`` and ``hl_keywords``/``ll_keywords`` below for the exact shape); ``metadata.model`` reads ``context-mode-no-llm`` and ``metadata.mode`` reports the search mode actually used (which may not be the one requested — see ``hl_keywords``).\n\n**Empty graph:** If no documents have been indexed yet, the endpoint returns ``answer='Недостаточно данных для ответа.'`` without calling the LLM.\n\n**Cost cap:** Requests are blocked with 402 when the tenant's daily cost cap is reached (``error=cost_cap_exceeded``; comes back within 24 hours), OR — on the free tier — when the calendar-month cap is reached (``error=cost_cap_monthly_exceeded``; the body carries ``retry_after`` seconds and the response the ``Retry-After`` header: the allowance comes back on the 1st of the next month, 00:00 UTC; when both caps are used up the answer is the monthly one), OR when the platform-wide daily spend cap is reached and this tenant is billed on the platform key (``error=platform_budget_paused``, ADR-066 — the body carries no ``cap`` field for this reason, unlike the per-tenant one; a tenant on their own key is never affected). Neither cap applies to an API-key request — it spends nothing.\n\n**modes local / global / naive:** x-experimental — may return an empty answer when the knowledge graph is sparse.","operationId":"query_knowledge_graph_v1_query_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryResponse"}}}},"429":{"description":"Daily query quota used up, OR too many simultaneous questions from this client (``detail.error = too_many_concurrent_queries``, with ``limit`` and ``retry_after`` = 10; the ``Retry-After: 10`` header says when to retry). Only a question answered by our model (a dashboard session) counts; an API-key question never does."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/query/stream":{"post":{"tags":["query"],"summary":"Stream a knowledge-graph query response (SSE).","description":"Stream a natural-language query response via Server-Sent Events (ADR-015 SSE framing protocol v1).\n\n**Pre-flight cost cap:** If the tenant's daily cost cap is already exceeded, the server returns HTTP 402 **before** opening the SSE stream (ADR-015 §Cost-cap behaviour). Body: ``{\"error\": \"cost_cap_exceeded\", \"current_spend\": \"<decimal>\", \"cap\": \"<decimal>\"}``. The same pre-flight gate also returns 402 ``{\"error\": \"platform_budget_paused\"}`` when the platform-wide daily spend cap is reached and this tenant is billed on the platform key (ADR-066); a tenant on their own key is unaffected.\n\n**Chunk ordering (normative):** ``token*`` → ``source*`` → ``done``.\n\n**Stage 1 stub:** emits 2 hard-coded token chunks + 1 done chunk.\n\n**Versioning:** Response header ``X-Andish-Stream-Version: 1`` on every SSE response.  Send ``X-Andish-Stream-Version-Accepted: 1`` for v2 upgrade negotiation (ADR-015 §Versioning).","operationId":"stream_query_v1_query_stream_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StreamQueryRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/graph/entities/search":{"get":{"tags":["graph"],"summary":"Search entities by label substring.","description":"Case-insensitive ILIKE search on entity labels (node_ids) in the current tenant workspace.\n\n**Relevance note:** uses ILIKE '%q%' without ranking. W3+: replace with pg_trgm similarity index.","operationId":"search_entities_v1_graph_entities_search_get","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":1,"maxLength":200,"description":"Search query string.","title":"Q"},"description":"Search query string."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of results.","default":20,"title":"Limit"},"description":"Maximum number of results."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntitySearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/graph/entities/{entity_id}/neighbors":{"get":{"tags":["graph"],"summary":"Return entities connected to entity_id.","description":"Returns direct (hop=1) or transitive (hop=2) neighbors of the given entity within the tenant workspace.\n\n**hop=2 cap:** total neighbors are capped by `limit` parameter.\n\n**Isolation:** 404 is returned when entity_id exists in another tenant's workspace (uniform; no information leakage).","operationId":"get_entity_neighbors_v1_graph_entities__entity_id__neighbors_get","parameters":[{"name":"entity_id","in":"path","required":true,"schema":{"type":"string","minLength":1,"maxLength":500,"description":"Stable entity id.","title":"Entity Id"},"description":"Stable entity id."},{"name":"hop","in":"query","required":false,"schema":{"type":"integer","maximum":2,"minimum":1,"description":"Hop depth (1 or 2).","default":1,"title":"Hop"},"description":"Hop depth (1 or 2)."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"description":"Maximum number of neighbors.","default":50,"title":"Limit"},"description":"Maximum number of neighbors."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NeighborResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/graph":{"get":{"tags":["graph"],"summary":"Return the tenant's knowledge graph.","description":"Return entity nodes and relationship edges for the current tenant's workspace.  Shape is compatible with react-force-graph-2d ``graphData`` prop.\n\n**List mode** (no ``root``): returns the top-N most-connected nodes ordered by degree DESC, node_id ASC.  Paginate with ``limit`` and ``cursor``.\n\n**Focus mode** (``root`` set): BFS traversal from the given node label up to ``depth`` hops (1–3, default 2).  Returns at most ``limit`` nodes ordered by degree DESC.  Cursor pagination does not apply in focus mode (BFS result is bounded by max_nodes, not paginated).\n\n**Pagination (list mode):** use ``limit`` (default 200, max 1000) and ``cursor`` (opaque string — forward the ``X-Next-Cursor`` header).\nWhen a next page exists the response header ``X-Next-Cursor`` is set and ``metadata.truncated`` is ``true``.\n\n**RLS:** only entities belonging to the authenticated workspace are returned.  Empty workspace → empty lists, never stub data.\n\n**Metadata semantics (list mode):** ``metadata.total_nodes`` is the workspace-wide count; ``metadata.truncated`` is True when any cap was applied (page cap OR workspace-exceeds-limit).\n\n**Metadata semantics (focus mode):** ``metadata.total_nodes`` is the workspace-wide count; ``metadata.truncated`` is True when the BFS result exceeded ``limit`` nodes.","operationId":"get_graph_v1_graph_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"description":"Maximum number of nodes per page (list mode) or max BFS nodes (focus mode).  Default 200, max 1000.","default":200,"title":"Limit"},"description":"Maximum number of nodes per page (list mode) or max BFS nodes (focus mode).  Default 200, max 1000."},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Opaque pagination cursor (list mode only).  Pass the value of the ``X-Next-Cursor`` response header to fetch the next page.  Ignored when ``root`` is set.","title":"Cursor"},"description":"Opaque pagination cursor (list mode only).  Pass the value of the ``X-Next-Cursor`` response header to fetch the next page.  Ignored when ``root`` is set."},{"name":"root","in":"query","required":false,"schema":{"anyOf":[{"type":"string","minLength":1,"maxLength":500},{"type":"null"}],"description":"Focus-mode root node label.  When set, the endpoint performs a BFS traversal from this node instead of the list-mode top-N query.  Non-existent root → empty graph (not 404).  Cross-tenant root → empty graph (RLS isolation).","title":"Root"},"description":"Focus-mode root node label.  When set, the endpoint performs a BFS traversal from this node instead of the list-mode top-N query.  Non-existent root → empty graph (not 404).  Cross-tenant root → empty graph (RLS isolation)."},{"name":"depth","in":"query","required":false,"schema":{"type":"integer","maximum":3,"minimum":1,"description":"BFS depth in focus mode (1–3).  Ignored when ``root`` is not set.  Default 2.","default":2,"title":"Depth"},"description":"BFS depth in focus mode (1–3).  Ignored when ``root`` is not set.  Default 2."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/graph/nodes/{node_id}":{"get":{"tags":["graph"],"summary":"Return full detail for a single entity node.","description":"Returns description, raw entity_type, and source documents for the given entity node within the authenticated workspace.\n\n**ADR-056:** this is the click-time detail endpoint. The list endpoint ``GET /v1/graph`` returns lightweight ``GraphNode`` without description to avoid payload bloat.\n\n**Isolation:** 404 is returned uniformly when node_id exists in another tenant's workspace (no information leakage).\n\n**Sources:** provenance resolved via ``lightrag_vdb_entity_*`` (chunk_ids) → ``lightrag_doc_chunks`` (full_doc_id). ``lightrag_graph_nodes.source_chunk_ids`` is not used — LightRAG 1.5.x does not populate it during extraction. Empty list when provenance is unavailable.","operationId":"get_graph_node_v1_graph_nodes__node_id__get","parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","minLength":1,"maxLength":500,"description":"Stable entity node_id (URL-encoded if it contains slashes).","title":"Node Id"},"description":"Stable entity node_id (URL-encoded if it contains slashes)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphNodeDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/graph/edges/":{"get":{"tags":["graph"],"summary":"Return full detail for a single relationship edge.","description":"Returns description, full keywords array, and raw weight (without clamping) for the edge between ``source`` and ``target`` nodes.\n\n**ADR-056:** click-time detail endpoint. The list endpoint ``GET /v1/graph`` returns lightweight ``GraphEdge`` with only the first keyword and weight clamped to [0, 1].\n\n**weight:** raw stored value — values > 1.0 indicate the edge was confirmed by multiple source chunks (reinforced relationship).\n\n**Isolation:** 404 is returned uniformly for cross-tenant or missing edges.","operationId":"get_graph_edge_v1_graph_edges__get","parameters":[{"name":"source","in":"query","required":true,"schema":{"type":"string","minLength":1,"maxLength":500,"description":"Source node id.","title":"Source"},"description":"Source node id."},{"name":"target","in":"query","required":true,"schema":{"type":"string","minLength":1,"maxLength":500,"description":"Target node id.","title":"Target"},"description":"Target node id."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GraphEdgeDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/workspace":{"delete":{"tags":["workspace"],"summary":"Erase the entire workspace (Task A4).","description":"Irreversibly erases the whole workspace: every live person's email and password hash are wiped, every API key is revoked, and every document is soft-deleted alongside the tenant itself. Admin-only, JWT session only (API keys get 401 jwt_required — mirrors DELETE /v1/auth/profile, STRIDE A1). Requires two confirmations: the exact workspace name (confirm_name) AND the caller's own current password (current_password) — a step-up control the security review made a hard requirement (docs/security/stride-team-mode-a4-2026-09-01.md). Deletion is immediate and irreversible: there is no cooling-off period (CEO 2026-09-01). To leave the workspace WITHOUT erasing it for everyone else, use DELETE /v1/auth/profile instead.","operationId":"delete_workspace_v1_workspace_delete","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceDeleteRequest"}}},"required":true},"responses":{"200":{"description":"Workspace erased — PII wiped for every live person.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceDeleteResponse"}}}},"401":{"description":"API-key auth attempted (jwt_required), or the caller's user/tenant pair does not resolve (user_tenant_mismatch)."},"403":{"description":"Insufficient scope — requires 'admin'."},"410":{"description":"Workspace already erased (workspace_deleted)."},"422":{"description":"invalid_password — current_password does not match, checked before confirm_name. workspace_name_mismatch — confirm_name does not match the workspace's name exactly."},"429":{"description":"Rate limit exceeded (Tier 1 — 10 req/min per IP)."}}},"patch":{"tags":["workspace"],"summary":"Update workspace settings.","description":"Partial update of workspace-level settings. Currently supports freshness_threshold_days (AC-FRESH-1.6): controls how many days before a document is considered stale in query responses. Valid range: 1..365. Pass null to reset to platform default (90 days). RLS-enforced — only affects the authenticated tenant. Requires 'admin' scope.","operationId":"patch_workspace_settings_v1_workspace_patch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceSettingsPatch"}}},"required":true},"responses":{"200":{"description":"Updated workspace settings.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceSettingsResponse"}}}},"403":{"description":"Insufficient scope — requires 'admin'."},"422":{"description":"Request body invalid (e.g. freshness_threshold_days out of range)."}}}},"/v1/workspace/usage":{"get":{"tags":["workspace"],"summary":"Return usage metrics for the authenticated workspace.","description":"Returns current-period cost, document count, and daily query count for the caller's workspace.  All values are scoped to the tenant derived from the Bearer API key (RLS enforced).  Response time target: p95 < 100ms.","operationId":"get_workspace_usage_v1_workspace_usage_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceUsageResponse"}}}}}}},"/v1/workspace/byok-key":{"post":{"tags":["workspace"],"summary":"Set a BYOK LLM provider API key for this workspace.","description":"Encrypts and persists a bring-your-own-key (BYOK) provider API key for the authenticated workspace (AES-256-GCM, ADR-036). ADR-049: multi-provider BYOK — now accepts provider type + api_key + optional model. Requires AES L1 encryption to be enabled on this deployment (ANDISH_ENCRYPTION_L1_ENABLED=true). Supported providers: openrouter_byok, polza_byok, ollama_byok. Plaintext key is NEVER logged or returned (ADR-019). Requires 'admin' scope.","operationId":"set_byok_key_v1_workspace_byok_key_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ByokKeySetRequest"}}},"required":true},"responses":{"204":{"description":"Key accepted and encrypted. No content returned."},"403":{"description":"Insufficient scope — requires 'admin'."},"412":{"description":"The workspace's spend is stopped by the operator: the client's own model key cannot be connected.","content":{"application/json":{"example":{"detail":{"error":"byok_required","msg":"..."}}}}},"422":{"description":"Request body invalid or api_key format rejected.","content":{"application/json":{"example":{"detail":{"error":"invalid_byok_key","msg":"..."}}}}},"503":{"description":"AES L1 encryption is not enabled on this deployment.","content":{"application/json":{"example":{"detail":{"error":"byok_encryption_disabled","msg":"BYOK requires ANDISH_ENCRYPTION_L1_ENABLED=true"}}}}}}},"delete":{"tags":["workspace"],"summary":"Remove the BYOK key for this workspace.","description":"Clears the stored BYOK key; the workspace reverts to the platform default LLM provider. Idempotent — returns 204 even when no key was set. Requires 'admin' scope.","operationId":"clear_byok_key_v1_workspace_byok_key_delete","responses":{"204":{"description":"Key cleared (or was already absent)."},"403":{"description":"Insufficient scope — requires 'admin'."}}}},"/v1/workspace/byok-key/status":{"get":{"tags":["workspace"],"summary":"Check whether a BYOK key is configured for this workspace.","description":"ADR-049: returns is_set + provider type + model — enables the frontend to render the correct UI state per provider without additional fetches. Plaintext key is NEVER returned. Requires 'admin' scope.","operationId":"get_byok_key_status_v1_workspace_byok_key_status_get","responses":{"200":{"description":"BYOK configuration status. Plaintext never returned.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ByokKeyStatusResponse"},"example":{"is_set":true,"provider":"polza_byok","model":"google/gemini-2.5-flash-lite"}}}},"403":{"description":"Insufficient scope — requires 'admin'."}}}},"/v1/workspace/llm/test":{"post":{"tags":["workspace"],"summary":"Validate BYOK LLM credentials and persist on success.","description":"ADR-013 §5.3: instantiates a throw-away provider with the supplied credentials, issues a minimal ping (max_tokens=1), and persists the encrypted key only on success. Supports: openrouter_byok (sk-or-v1-...), polza_byok (any format), and ollama_byok (self-hosted Ollama; uses base_url field; no key persisted). Rate-limited: 5 attempts per tenant per 10 minutes. Plaintext key is NEVER logged or returned (ADR-019). Requires 'admin' scope.","operationId":"test_llm_provider_v1_workspace_llm_test_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LlmTestRequest"}}},"required":true},"responses":{"200":{"description":"Validation result. See status field for outcome.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LlmTestResponse"}}}},"403":{"description":"Insufficient scope — requires 'admin'."},"412":{"description":"The workspace's spend is stopped by the operator: the client's own model key cannot be connected. Answered before any external request and before the attempt counter.","content":{"application/json":{"example":{"detail":{"error":"byok_required","msg":"..."}}}}},"422":{"description":"Request body invalid."},"429":{"description":"Rate limit exceeded — 5 attempts per tenant per 10 minutes.","content":{"application/json":{"example":{"detail":{"error":"rate_limit_exceeded","retry_after":600}}}}},"503":{"description":"AES L1 encryption not enabled — cannot persist key."}}}},"/v1/waitlist":{"post":{"tags":["waitlist"],"summary":"Join the pre-launch waitlist.","description":"Create a new waitlist signup and send confirmation email.\n\nEmail is normalised to lowercase before duplicate check and storage.\nconsent_fz152 must be True (validated at Pydantic layer — 422 if False).\nDuplicate email returns 409 (idempotent — safe for retry with same email).","operationId":"create_signup_v1_waitlist_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistSignupRequest"}}},"required":true},"responses":{"202":{"description":"Signup accepted — confirmation email dispatched.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistSignupResponse"}}}},"409":{"description":"Email already on the waitlist."},"422":{"description":"Validation error or consent_fz152=False."},"429":{"description":"Rate limit exceeded."}}}},"/v1/waitlist/confirm":{"get":{"tags":["waitlist"],"summary":"Confirm waitlist signup via emailed token.","description":"Confirm a waitlist signup.\n\nLooks up the SHA-256 hash of the provided token. On match:\n  - Sets confirmed=True, confirmed_at=NOW().\n  - Clears confirm_token_hash (one-use).\n  - Sends email_2_confirmed.\n\nToken TTL: 72 hours. Expired tokens return 410 Gone.","operationId":"confirm_signup_v1_waitlist_confirm_get","parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"Plaintext confirmation token from email link.","title":"Token"},"description":"Plaintext confirmation token from email link."}],"responses":{"200":{"description":"Email confirmed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistConfirmResponse"}}}},"404":{"description":"Token not found."},"410":{"description":"Token expired (TTL 72h)."},"422":{"description":"Missing token parameter."}}}},"/v1/waitlist/unsubscribe":{"post":{"tags":["waitlist"],"summary":"Unsubscribe from the waitlist via emailed link.","description":"Mark a signup as unsubscribed (confirmed=False, token cleared).\n\nThe email link carries a plaintext token. This endpoint hashes it (SHA-256)\nand looks up the stored hash — ADR-019 compliant (plaintext never in DB).","operationId":"unsubscribe_v1_waitlist_unsubscribe_post","parameters":[{"name":"token","in":"query","required":true,"schema":{"type":"string","description":"Unsubscribe token from email link.","title":"Token"},"description":"Unsubscribe token from email link."}],"responses":{"200":{"description":"Successfully unsubscribed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WaitlistUnsubscribeResponse"}}}},"404":{"description":"Token not found."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/feedback":{"post":{"tags":["feedback"],"summary":"Submit user feedback (anonymous, rate-limited).","description":"Accept a feedback submission from the form (public, no auth required).\n\nWrites the record to the feedback table via the superuser session and then\nfires a best-effort Telegram relay.  The relay is attempted AFTER commit so\nthat a relay error never rolls back the DB record.\n\ncontact is stored in DB for private replies but is NEVER included in the\nTelegram relay text (ADR-060 §2 hard rule, ФЗ-152 / PII).\nip_address is captured for anti-abuse but NOT logged via structlog.","operationId":"create_feedback_v1_feedback_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackCreateRequest"}}},"required":true},"responses":{"202":{"description":"Feedback accepted — stored and relayed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackCreateResponse"}}}},"422":{"description":"Validation error (missing message, too long, etc.)."},"429":{"description":"Rate limit exceeded (10 req/min per IP)."}}}}},"components":{"schemas":{"ApiKeyCreateRequest":{"properties":{"scopes":{"items":{"type":"string","enum":["read","write"]},"type":"array","minItems":1,"title":"Scopes","description":"Permission scopes for the new key.  Allowed values: read, write.  Defaults to [read, write] — parity with the onboarding key.  Pass [read] explicitly for a read-only key.","default":["read","write"]},"name":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Name","description":"Optional human-readable label for the key (≤ 64 chars after trimming).  ASCII control characters are stripped.  Empty string or whitespace-only is treated as no label (None)."}},"type":"object","title":"ApiKeyCreateRequest","description":"Request body for POST /v1/api-keys.\n\nscopes defaults to [\"read\", \"write\"] — matching the onboarding key so that\nSettings-created keys work out of the box for both uploads and queries.\nThe caller may pass [\"read\"] explicitly to create a read-only key (e.g. for\na dashboard widget).  An empty list is rejected (min_length=1) so the caller\ncannot accidentally issue a zero-scope key that fails all auth checks.\n\n\"admin\" scope is blocked at this layer (STRIDE E2): admin keys are issued\nonly by the system, never through self-service.  Duplicate values are\ndeduplicated by the service layer.\n\nname is an optional human-readable label (BUG-APIKEY-DISPLAY / ADR-059).\nValidated: trimmed, control chars stripped, empty-after-trim → None, max 64 chars.\nUnicode (including Cyrillic) is allowed."},"ApiKeyCreateResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"prefix":{"type":"string","title":"Prefix"},"scopes":{"items":{"type":"string"},"type":"array","title":"Scopes"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"api_key":{"type":"string","title":"Api Key","description":"Plaintext API key — shown ONCE on creation.  Copy it now; it cannot be retrieved again."}},"type":"object","required":["id","prefix","scopes","created_at","api_key"],"title":"ApiKeyCreateResponse","description":"Response for POST /v1/api-keys.\n\napi_key is the plaintext credential — returned EXACTLY ONCE (AC #1).\nAfter this response the plaintext is unrecoverable; the database only\nstores the bcrypt hash."},"ApiKeyListResponse":{"properties":{"api_keys":{"items":{"$ref":"#/components/schemas/ApiKeyResponse"},"type":"array","title":"Api Keys"},"active_key_count":{"type":"integer","title":"Active Key Count","description":"Number of currently active (non-revoked) API keys for this tenant."},"active_key_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Active Key Limit","description":"Tier's active-key limit. None means unlimited (business/enterprise). The Dashboard must render this value, not a hardcoded number."}},"type":"object","required":["api_keys","active_key_count","active_key_limit"],"title":"ApiKeyListResponse","description":"Envelope for GET /v1/api-keys.\n\nactive_key_count / active_key_limit (FREE-TIER-TIGHTEN AC #5): the\nDashboard renders \"used N of M\" from these fields — it must never\nhardcode the tier limit itself. active_key_limit is None when the tier\nhas no upper bound (business/enterprise)."},"ApiKeyResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"prefix":{"type":"string","title":"Prefix"},"scopes":{"items":{"type":"string"},"type":"array","title":"Scopes"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"revoked_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Revoked At"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"body_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Body Hint"}},"type":"object","required":["id","prefix","scopes","created_at"],"title":"ApiKeyResponse","description":"List-item shape for GET /v1/api-keys (AC #4, #5).\n\nkey_hash and plaintext are NEVER present in this schema (STRIDE I1/I3).\nrevoked_at is included so the UI can show revocation status.\nname and body_hint added for BUG-APIKEY-DISPLAY / ADR-059."},"ByokKeySetRequest":{"properties":{"provider":{"type":"string","enum":["openrouter_byok","polza_byok","ollama_byok"],"title":"Provider","description":"BYOK provider type. 'openrouter_byok': OpenRouter (key must start with sk-or-v1-). 'polza_byok': polza.ai RU gateway (any non-empty Bearer token). 'ollama_byok': self-hosted Ollama (no API key required)."},"api_key":{"type":"string","maxLength":512,"title":"Api Key","description":"Plaintext API key for the selected provider. Required and validated for openrouter_byok and polza_byok. May be empty or omitted for ollama_byok (Ollama has no auth token). Stored AES-256-GCM encrypted per ADR-036. Plaintext is never logged or echoed back (ADR-019).","default":""},"model":{"anyOf":[{"type":"string","maxLength":128,"minLength":1},{"type":"null"}],"title":"Model","description":"Optional model identifier to persist with the BYOK key. Used by build_llm_provider_for_tenant to route requests to the correct model without re-prompting. polza.ai example: 'google/gemini-2.5-flash-lite' (catalog ids always carry a vendor prefix — a bare 'gpt-4o-mini' is invalid on polza.ai). openrouter example: 'google/gemini-2.5-flash-lite'. If omitted, provider default is used."}},"type":"object","required":["provider"],"title":"ByokKeySetRequest","description":"Request body for POST /v1/workspace/byok-key.\n\nADR-049: multi-provider BYOK — provider type, api_key, and optional model\nare now required/accepted fields. The old single-field {api_key} body is\nreplaced by {provider, api_key, model} to support polza_byok and ollama_byok.\n\nADR-013 §5.3 log hygiene: the api_key value is a bearer credential —\nit is encrypted on write, never logged, and never returned in any response.\n\nProvider-specific validation:\n  openrouter_byok — api_key must match ^sk-or-v1-[a-zA-Z0-9_-]+$ (OpenRouter format).\n  polza_byok      — api_key must be non-empty (any Bearer token format).\n  ollama_byok     — api_key may be empty or omitted (Ollama has no auth token);\n                    model is stored for display; base_url deferred (Phase A)."},"ByokKeyStatusResponse":{"properties":{"is_set":{"type":"boolean","title":"Is Set","description":"True when a BYOK key (or Ollama config) is configured for this workspace. Plaintext key is never returned by this endpoint."},"provider":{"anyOf":[{"type":"string","enum":["openrouter_byok","polza_byok","ollama_byok"]},{"type":"null"}],"title":"Provider","description":"The configured BYOK provider type. Null when is_set=false. One of: 'openrouter_byok', 'polza_byok', 'ollama_byok'."},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model","description":"The model override stored with the BYOK key. Null when is_set=false or no model override was specified. Plaintext key is never returned — only the model identifier."}},"type":"object","required":["is_set"],"title":"ByokKeyStatusResponse","description":"Response for GET /v1/workspace/byok-key/status.\n\nADR-049: expands from {is_set: bool} to include provider type and model\nso that the frontend can render the correct UI state per provider.\n\nPlaintext key is NEVER returned — only presence flag + provider metadata."},"ComponentHealth":{"properties":{"status":{"type":"string","enum":["healthy","unhealthy","degraded","not_ready","unknown"],"title":"Status"},"latency_ms":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Latency Ms"},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"}},"type":"object","required":["status"],"title":"ComponentHealth","description":"Health status of a single infrastructure component."},"ConsentStatus":{"properties":{"version":{"type":"integer","title":"Version","description":"ToS document version (1 = current Terms of Service)."},"signed_at":{"type":"string","format":"date-time","title":"Signed At","description":"Timestamp when consents were accepted."},"agreed_terms_v1":{"type":"boolean","title":"Agreed Terms V1"},"agreed_no_pdn":{"type":"boolean","title":"Agreed No Pdn"},"agreed_processing_metadata":{"type":"boolean","title":"Agreed Processing Metadata"},"agreed_crossborder_llm":{"type":"boolean","title":"Agreed Crossborder Llm"}},"type":"object","required":["version","signed_at","agreed_terms_v1","agreed_no_pdn","agreed_processing_metadata","agreed_crossborder_llm"],"title":"ConsentStatus","description":"Consent audit-trail snapshot embedded in MeResponse."},"ContextChunk":{"properties":{"text":{"type":"string","title":"Text"},"document_id":{"type":"string","title":"Document Id"},"chunk_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chunk Id"},"document_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Document Name"},"document_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Document Date"},"loaded_days_ago":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Loaded Days Ago"},"freshness":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Freshness"}},"type":"object","required":["text","document_id"],"title":"ContextChunk","description":"Single chunk in context-mode response.\n\nGuaranteed fields: text, document_id (AC-2).\nOptional fields may be added in future versions without a /v2/query bump\n(ADR-012 rule b — additive only)."},"ContextEntity":{"properties":{"name":{"type":"string","title":"Name"},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"type":"object","required":["name"],"title":"ContextEntity","description":"Single entity in context-mode response.\n\nGuaranteed field: name (AC-2)."},"ContextRelation":{"properties":{"source":{"type":"string","title":"Source"},"target":{"type":"string","title":"Target"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"keywords":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Keywords"},"weight":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Weight"}},"type":"object","required":["source","target"],"title":"ContextRelation","description":"Single relation in context-mode response.\n\nGuaranteed fields: source, target (AC-2)."},"DeleteProfileResponse":{"properties":{"deleted_at":{"type":"string","format":"date-time","title":"Deleted At","description":"UTC timestamp when the account was soft-deleted."},"data_removal_eta":{"type":"string","title":"Data Removal Eta","description":"Human-readable note on data removal. Personal data (email, password hash) is wiped immediately and cannot be undone; this field describes only the (not yet scheduled) physical purge of the remaining soft-deleted rows — it is never a promise that the deletion itself can be reversed."},"workspace_deleted":{"type":"boolean","title":"Workspace Deleted","description":"True only when the caller was the workspace's last living person and the whole workspace was soft-deleted along with them. False means the caller left and the workspace, with its documents and the other members' keys, is untouched.","default":false}},"type":"object","required":["deleted_at","data_removal_eta"],"title":"DeleteProfileResponse","description":"Response for DELETE /v1/auth/profile (HTTP 200).\n\nConfirms the caller has been soft-deleted. Task A4 (docs/technical/\nteam-mode-a4-contract-2026-09-01.md) distinguishes \"I left the workspace\"\n(workspace_deleted=false — other people remain) from \"I was the last\nperson, the workspace left with me\" (workspace_deleted=true).\n\nCEO 2026-09-01 frame: deletion is immediate and irreversible from the\nmoment it is requested — there is no 30-day cooling-off / undo window.\ndata_removal_eta describes ONLY the (currently unscheduled) physical\npurge of already-wiped rows — never restate the old \"30 days\" promise here.\n\nWire format: {\"deleted_at\": \"...\", \"data_removal_eta\": \"...\", \"workspace_deleted\": false}\n\nIMPORTANT: the field name `workspace_deleted` is load-bearing — Task A1's\nself-arming test (tests/security/test_invite_requires_a4_gate.py) keys off\nthis exact name to prove invites cannot ship ahead of A4. Do not rename it."},"DeletionImpactResponse":{"properties":{"chunks":{"type":"integer","title":"Chunks"},"edges":{"type":"integer","title":"Edges"},"entities_exclusive":{"type":"integer","title":"Entities Exclusive"},"entities_shared":{"type":"integer","title":"Entities Shared"},"vectors":{"type":"integer","title":"Vectors"}},"type":"object","required":["chunks","edges","entities_exclusive","entities_shared","vectors"],"title":"DeletionImpactResponse","description":"Serialisable snapshot of DeletionImpact (E7.2 / E7.3).\n\nField names use the short JSON keys mandated by ADR-011 §Decision:\n``chunks``, ``edges``, ``entities_exclusive``, ``entities_shared``,\n``vectors`` — no ``_count`` suffix in the wire format."},"DetailedHealthResponse":{"properties":{"status":{"type":"string","enum":["healthy","degraded","unhealthy"],"title":"Status"},"version":{"type":"string","title":"Version"},"components":{"additionalProperties":{"$ref":"#/components/schemas/ComponentHealth"},"type":"object","title":"Components"}},"type":"object","required":["status","version","components"],"title":"DetailedHealthResponse","description":"Response body for GET /v1/health/detailed."},"DocumentCreateRequest":{"properties":{"content":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Content","description":"Raw text to index (max 1 MB UTF-8). Documents above 15 000 words trigger automatic split into 4 parts (XL path); 50 000 words is the hard cap (HTTP 413 ``payload_too_large_xl_cap``, detail includes ``words`` — the actual word count — and ``parts_hint`` — the suggested number of parts to split into). Required when file_format='text' (default). Omit when providing file_content_b64 for binary uploads."},"file_content_b64":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Content B64","description":"Base64-encoded bytes of a binary document file.  Required when file_format is one of: 'pdf', 'docx', 'pptx', 'xlsx', 'csv', 'html', 'rtf', 'odt'.  The raw byte length (after decoding) is subject to the tenant's file_size_cap_mb limit.  CSV and HTML files must be sent via this field (not 'content') so the server can handle RU cp1251 encoding correctly."},"file_format":{"type":"string","enum":["pdf","docx","text","pptx","xlsx","csv","html","rtf","odt"],"title":"File Format","description":"'text' (default) — send content as plain UTF-8 string. 'pdf' — base64 PDF, extracted via pypdf. 'docx' — base64 DOCX, extracted via python-docx. 'pptx' — base64 PPTX, extracted via python-pptx. 'xlsx' — base64 XLSX, extracted via openpyxl. 'csv' — base64 CSV bytes, decoded server-side (UTF-8 / cp1251). 'html' — base64 HTML bytes, decoded and stripped server-side. 'rtf' — base64 RTF, extracted via striprtf. 'odt' — base64 ODT, extracted via zip+lxml (no odfpy).","default":"text"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata","description":"Arbitrary key-value metadata stored alongside the document."},"document_date":{"anyOf":[{"type":"string","maxLength":32},{"type":"null"}],"title":"Document Date","description":"Optional human-readable validity date. Accepted formats: ISO date ('2024-12-15'), quarter ('2024-Q3'), year ('2024'), or any label ≤32 characters. Stored as-is; not parsed or validated as a date."}},"type":"object","title":"DocumentCreateRequest","description":"Request body for POST /v1/documents.\n\nSupports two ingestion modes:\n\n**Text mode** (default, backward-compatible)::\n\n    {\n        \"content\": \"Hello world\",\n        \"metadata\": {\"source\": \"api\"}\n    }\n\n**Binary mode** (PDF / DOCX upload via base64)::\n\n    {\n        \"file_content_b64\": \"<base64-encoded bytes>\",\n        \"file_format\": \"pdf\",\n        \"metadata\": {\"source\": \"upload\"}\n    }\n\nExactly one of ``content`` or ``file_content_b64`` must be provided.\n``file_format`` defaults to ``\"text\"`` — omit it when sending plain text."},"DocumentCreateResponse":{"properties":{"document_id":{"type":"string","title":"Document Id"},"status":{"type":"string","title":"Status"},"status_url":{"type":"string","title":"Status Url"},"is_split":{"type":"boolean","title":"Is Split","default":false}},"type":"object","required":["document_id","status","status_url"],"title":"DocumentCreateResponse","description":"202 response for POST /v1/documents.\n\nADR-037 §2.8: ``is_split=True`` when the upload triggered the XL parent/part\npath.  User-facing UI ignores this field; internal/admin consumers may branch\non it.  Defaults to False for backward-compatible single-doc responses."},"DocumentEntitiesResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/DocumentEntityRef"},"type":"array","title":"Items","description":"Top entities of this document, ordered by degree DESC, name ASC."},"total":{"type":"integer","minimum":0.0,"title":"Total","description":"Total distinct entities linked to this document."},"truncated":{"type":"boolean","title":"Truncated","description":"True when ``total`` exceeds the number of returned ``items``."}},"type":"object","required":["items","total","truncated"],"title":"DocumentEntitiesResponse","description":"200 response for GET /v1/documents/{document_id}/entities."},"DocumentEntityRef":{"properties":{"name":{"type":"string","title":"Name","description":"Entity node_id / label."},"type":{"type":"string","title":"Type","description":"Canonical entity type (GRAPH_NODE_TYPES taxonomy)."},"degree":{"type":"integer","minimum":0.0,"title":"Degree","description":"Workspace-wide connectivity (incident edge count)."}},"type":"object","required":["name","type","degree"],"title":"DocumentEntityRef","description":"A single entity extracted from one document, for prompt-building.\n\n``degree`` is the node's workspace-wide connectivity (same column as\n``GraphNode.weight`` on ``GET /v1/graph``), used to rank the most\n\"central\" entities of the document first. It is NOT scoped to this\ndocument's edges only — it is the entity's overall degree in the graph."},"DocumentListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/DocumentResponse"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"queue_depth":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Queue Depth"},"estimated_wait_seconds":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Estimated Wait Seconds"}},"type":"object","required":["items","total","limit","offset"],"title":"DocumentListResponse","description":"Paginated list of documents for the current tenant."},"DocumentResponse":{"properties":{"document_id":{"type":"string","title":"Document Id"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"meta":{"additionalProperties":true,"type":"object","title":"Meta"},"progress_percent":{"type":"integer","title":"Progress Percent"},"error_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message"},"processed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Processed At"},"content":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content"},"deletion_impact":{"anyOf":[{"$ref":"#/components/schemas/DeletionImpactResponse"},{"type":"null"}]},"is_parent":{"type":"boolean","title":"Is Parent","default":false},"document_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Document Date"},"last_queried_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Queried At"},"content_summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Summary"},"chunks_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Chunks Count"},"entity_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Entity Count"},"edge_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Edge Count"}},"type":"object","required":["document_id","status","created_at","updated_at","meta","progress_percent"],"title":"DocumentResponse","description":"Metadata response for a single document.\n\nE1.5 additions (backward-compatible — new fields only added, none removed):\n  - progress_percent: 0-100 extraction progress for UI polling.\n  - updated_at:       already existed; promoted to explicit field (was included).\n  - error_message:    present when status='failed' or 'partial'. Machine reasons set by the\n                      money ceilings: ``cost_cap_exceeded`` (24-hour ceiling, the\n                      allowance comes back within a day) and ``cost_cap_monthly_exceeded``\n                      (calendar-month ceiling of the free tier, comes back on the 1st,\n                      00:00 UTC; ADR-083). A ``partial`` XL parent carries the heaviest\n                      code of its failed parts (monthly over daily); a part failure for any\n                      other reason leaves the older \"N/M parts failed\" text there.\n                      ADR-091: when the engine did not finish a document the reason is one\n                      of ``model_unavailable`` (the parse model did not answer),\n                      ``index_write_failed`` (the model answered, the index could not be\n                      written) or ``processing_failed`` (anything else, the outcome could\n                      not be confirmed). A large document whose parts ALL failed is\n                      ``failed`` (not ``partial``) and carries the heaviest code of its\n                      parts: the two money codes first, then these three. Only the code is\n                      ever stored, never the text of an engine error.\n  - processed_at:     set when status='indexed'.\n\nADR-036 §2.6 addition:\n  - content:          decrypted plaintext returned to authenticated tenant owner.\n                      Present on GET /v1/documents/{id}; absent on list responses\n                      (content excluded from list for bandwidth — clients should\n                      fetch individual documents when they need the full text).\n\nADR-056 / BE-3 additions (Документы S — backward-compatible, all Optional/None):\n  - content_summary:  First ~100 chars of doc text from lightrag_doc_status\n                      (no LLM — raw excerpt). None when doc_status absent.\n  - chunks_count:     Number of text chunks extracted. None when unavailable.\n  - entity_count:     Graph nodes extracted from this document. None when unavailable.\n  - edge_count:       Graph edges extracted from this document. None when unavailable."},"EdgeRef":{"properties":{"source":{"type":"string","title":"Source","description":"Source entity id."},"target":{"type":"string","title":"Target","description":"Target entity id."},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type","description":"Relationship type label."}},"type":"object","required":["source","target"],"title":"EdgeRef","description":"A directed relationship between two entity nodes."},"EntityRef":{"properties":{"id":{"type":"string","title":"Id","description":"Stable entity identifier (node_id in graph storage)."},"label":{"type":"string","title":"Label","description":"Human-readable entity label."},"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type","description":"Entity sub-type (may be absent)."},"weight":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Weight","description":"Degree weight (may be absent)."}},"type":"object","required":["id","label"],"title":"EntityRef","description":"A reference to a single entity node.\n\n``id``     — stable node_id (opaque string, workspace-scoped).\n``label``  — human-readable entity label (same as node_id in LightRAG\n             adjacency storage; node_id IS the canonical label).\n``type``   — entity sub-type (e.g. \"PERSON\", \"ORGANIZATION\"); None when\n             the node was created as a stub endpoint without entity_type.\n``weight`` — degree from the degree column (migration 011); None when the\n             degree column has not been refreshed yet."},"EntitySearchResponse":{"properties":{"entities":{"items":{"$ref":"#/components/schemas/EntityRef"},"type":"array","title":"Entities","description":"Matched entity references, ordered by node_id."},"query":{"type":"string","title":"Query","description":"The search query string that was used."},"total":{"type":"integer","minimum":0.0,"title":"Total","description":"Total number of matched entities."}},"type":"object","required":["entities","query","total"],"title":"EntitySearchResponse","description":"200 response for GET /v1/graph/entities/search."},"FeedbackCreateRequest":{"properties":{"message":{"type":"string","maxLength":2048,"minLength":1,"title":"Message","description":"Feedback text (required). Control characters are stripped."},"category":{"anyOf":[{"type":"string","enum":["bug","idea","question"]},{"type":"null"}],"title":"Category","description":"Feedback category for triaging."},"contact":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Contact","description":"Optional contact (email or @handle) for private reply. Stored in DB only — never sent to Telegram (ФЗ-152 / PII). Requires consent_fz152=true when non-empty."},"page_url":{"anyOf":[{"type":"string","maxLength":2000},{"type":"null"}],"title":"Page Url","description":"URL of the page the form was submitted from (triaging context). Must start with http:// or https://."},"consent_fz152":{"type":"boolean","title":"Consent Fz152","description":"Explicit consent to process personal contact data per ФЗ-152 (Russian law). Required when contact is provided — request is rejected with 422 if False.","default":false}},"type":"object","required":["message"],"title":"FeedbackCreateRequest","description":"Request body for POST /v1/feedback."},"FeedbackCreateResponse":{"properties":{"status":{"type":"string","const":"accepted","title":"Status","default":"accepted"},"message":{"type":"string","title":"Message","default":"Feedback received."}},"type":"object","title":"FeedbackCreateResponse","description":"Response for 202 Accepted."},"GraphEdge":{"properties":{"source":{"type":"string","title":"Source","description":"Source node id (must match a GraphNode.id)."},"target":{"type":"string","title":"Target","description":"Target node id (must match a GraphNode.id)."},"type":{"type":"string","title":"Type","description":"Relationship label (e.g. 'uses', 'reports_to')."},"weight":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Weight","description":"Relationship strength in [0, 1].  Drives edge opacity / dash pattern."}},"type":"object","required":["source","target","type","weight"],"title":"GraphEdge","description":"A directed relationship between two entity nodes.\n\n``source`` and ``target`` must match ``GraphNode.id`` values in the same\n``GraphResponse``.  The frontend renders an undirected edge visually\n(react-force-graph-2d default) but the direction is preserved for future\nfiltering / export.\n``type``   -- relationship label shown on edge hover tooltip.\n``weight`` -- relationship strength in [0, 1]; controls edge opacity /\n              dash pattern: weight ≥ 0.5 → solid, < 0.5 → dashed."},"GraphEdgeDetail":{"properties":{"source":{"type":"string","title":"Source","description":"Source node id."},"target":{"type":"string","title":"Target","description":"Target node id."},"type":{"type":"string","title":"Type","description":"Relationship type label derived from keywords[0] (normalised lowercase snake_case). Falls back to 'related_to'."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Full relationship description from lightrag_graph_edges. None when the edge has no extracted description."},"keywords":{"items":{"type":"string"},"type":"array","title":"Keywords","description":"All relationship keywords as stored in lightrag_graph_edges.keywords[]. May be empty. GraphEdge.type uses only keywords[0]."},"weight":{"type":"number","minimum":0.0,"title":"Weight","description":"Raw relationship weight WITHOUT clamping to [0, 1]. Values > 1.0 indicate the edge was reinforced by multiple source chunks. GraphEdge.weight clamps this to [0, 1] for visual rendering."}},"type":"object","required":["source","target","type","weight"],"title":"GraphEdgeDetail","description":"Full detail for a single relationship edge — GET /v1/graph/edges/.\n\nReturned by the click-time detail endpoint (ADR-056).\nList endpoint (GET /v1/graph) returns lightweight GraphEdge instead.\n\nKey difference from GraphEdge:\n  - ``description`` is included (GraphEdge omits it).\n  - ``keywords`` is the full array (GraphEdge uses only keywords[0] as type).\n  - ``weight`` is the raw stored value WITHOUT clamp to [0,1].\n    Values > 1.0 indicate the edge was confirmed by multiple source chunks."},"GraphMetadata":{"properties":{"total_nodes":{"type":"integer","minimum":0.0,"title":"Total Nodes","description":"Total entity count in workspace."},"total_edges":{"type":"integer","minimum":0.0,"title":"Total Edges","description":"Total edge count in workspace."},"truncated":{"type":"boolean","title":"Truncated","description":"True when the returned graph is a subset of the workspace graph (top-500 by degree centrality, per PRD F17 acceptance criteria).","default":false}},"type":"object","required":["total_nodes","total_edges"],"title":"GraphMetadata","description":"Lightweight metadata returned alongside graph data.\n\n``total_nodes``  -- total entity count in workspace (may exceed ``nodes``\n                    length when the server applies a top-N cap per PRD F17).\n``total_edges``  -- total edge count in workspace.\n``truncated``    -- True when top-N cap was applied (PRD F17: top-500 by\n                    degree centrality when N > 500)."},"GraphNode":{"properties":{"id":{"type":"string","title":"Id","description":"Stable entity identifier (UUID or opaque string)."},"label":{"type":"string","title":"Label","description":"Human-readable entity name."},"type":{"type":"string","title":"Type","description":"Entity sub-type.  One of: Person, Organization, Concept, Method, Artifact, Location, Creature, Other.  Frontend maps each value to a distinct node colour."},"weight":{"type":"number","minimum":0.0,"title":"Weight","description":"Degree centrality weight (≥ 0). Proportional to the number of incident edges.  Used by react-force-graph-2d to size nodes."}},"type":"object","required":["id","label","type","weight"],"title":"GraphNode","description":"A single entity node in the knowledge graph.\n\n``id``     -- stable identifier; matches ``entity_id`` in SSE source chunks\n              (ADR-015 §source chunk) so the frontend can highlight on query.\n``label``  -- human-readable name shown on the canvas and in search results.\n``type``   -- entity sub-type string; drives node colour and legend icon.\n              One of: Person, Organization, Concept, Method, Artifact,\n              Location, Creature, Other.\n``weight`` -- degree centrality proxy (number of incident edges); drives\n              node size: larger weight → larger circle radius."},"GraphNodeDetail":{"properties":{"id":{"type":"string","title":"Id","description":"Stable entity identifier (node_id)."},"label":{"type":"string","title":"Label","description":"Human-readable entity name."},"type":{"type":"string","title":"Type","description":"Canonical entity type. One of: Person, Organization, Concept, Method, Artifact, Location, Creature, Other."},"entity_type_raw":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Entity Type Raw","description":"Raw entity_type value as stored (e.g. 'ARTIFACT'). None when absent."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Full entity description from lightrag_graph_nodes. None when the node has no extracted description."},"sources":{"items":{"$ref":"#/components/schemas/GraphSourceDoc"},"type":"array","title":"Sources","description":"Documents that contributed to this entity (via source_chunk_ids). Empty list when provenance is unavailable."}},"type":"object","required":["id","label","type"],"title":"GraphNodeDetail","description":"Full detail for a single entity node — GET /v1/graph/nodes/{id}.\n\nReturned by the click-time detail endpoint (ADR-056).\nList endpoint (GET /v1/graph) returns lightweight GraphNode instead.\n\n``id``            -- stable node_id (matches GraphNode.id in list).\n``label``         -- human-readable name (same as id in LightRAG storage).\n``type``          -- canonical display type (mapped via _entity_type_to_graph_type).\n``entity_type_raw`` -- raw entity_type string as stored (e.g. \"ARTIFACT\", \"person\").\n                       None when the node was created without a type.\n``description``   -- full description text as stored in lightrag_graph_nodes.\n                     May contain multiple fragments joined by the LightRAG\n                     GRAPH_FIELD_SEP separator. None when not yet extracted.\n``sources``       -- list of documents that contributed to this entity.\n                     Empty when source_chunk_ids is empty or chunks lack a doc."},"GraphResponse":{"properties":{"nodes":{"items":{"$ref":"#/components/schemas/GraphNode"},"type":"array","title":"Nodes","description":"Entity nodes in the workspace graph."},"edges":{"items":{"$ref":"#/components/schemas/GraphEdge"},"type":"array","title":"Edges","description":"Directed relationships between nodes."},"metadata":{"$ref":"#/components/schemas/GraphMetadata","description":"Workspace graph statistics."}},"type":"object","required":["nodes","edges","metadata"],"title":"GraphResponse","description":"200 response for GET /v1/graph.\n\nCompatible with react-force-graph-2d ``graphData`` prop shape:\n  { nodes: Node[], links: Link[] }\nThe frontend adapts ``edges`` → ``links`` in the component layer."},"GraphSourceDoc":{"properties":{"doc_id":{"type":"string","title":"Doc Id","description":"Document UUID (full_doc_id from lightrag_doc_chunks)."},"filename":{"type":"string","title":"Filename","description":"Human-readable file name (file_path from lightrag_doc_chunks)."}},"type":"object","required":["doc_id","filename"],"title":"GraphSourceDoc","description":"A source document that contributed to an entity node.\n\nDerived from lightrag_doc_chunks (file_path, full_doc_id).\nUsed in GraphNodeDetail.sources to surface provenance."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthResponse":{"properties":{"status":{"type":"string","enum":["healthy","unhealthy"],"title":"Status"},"version":{"type":"string","title":"Version"}},"type":"object","required":["status","version"],"title":"HealthResponse","description":"Response body for GET /v1/health (basic health check)."},"LlmTestRequest":{"properties":{"provider":{"type":"string","enum":["openrouter_byok","polza_byok","ollama_byok"],"title":"Provider","description":"BYOK provider type. 'openrouter_byok': OpenRouter (key format sk-or-v1-...). 'polza_byok': polza.ai RU gateway (Bearer token, any format). 'ollama_byok': self-hosted Ollama (no auth; use base_url field)."},"api_key":{"type":"string","maxLength":512,"title":"Api Key","description":"Plaintext API key for the selected provider. Required for openrouter_byok and polza_byok. Not used for ollama_byok (pass empty string or omit). Stored AES-256-GCM encrypted on success (ADR-036). Plaintext is never logged or echoed back (ADR-019).","default":""},"base_url":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}],"title":"Base Url","description":"Ollama base URL for ollama_byok provider. Example: 'http://localhost:11434' or 'http://192.168.1.10:11434'. Ignored for openrouter_byok and polza_byok."},"model":{"anyOf":[{"type":"string","maxLength":128,"minLength":1},{"type":"null"}],"title":"Model","description":"Optional model override for the validation ping. If omitted, a provider-appropriate default is used. polza.ai example: 'openai/gpt-4o-mini'. ollama_byok example: 'llama3' or 'mistral'."}},"type":"object","required":["provider"],"title":"LlmTestRequest","description":"Request body for POST /v1/workspace/llm/test.\n\nADR-013 §5.3: throw-away provider validation before key persistence.\nValidates that the supplied credentials work for the given provider,\nthen persists the encrypted key on success. Rate-limited: 5 attempts\nper tenant per 10 minutes (Redis counter).\n\nProvider-specific fields:\n  openrouter_byok / polza_byok: api_key is required; base_url is ignored.\n  ollama_byok: base_url is the Ollama endpoint (default localhost:11434);\n               api_key is not required (pass empty string or omit).\n               No key is persisted — only reachability is verified."},"LlmTestResponse":{"properties":{"status":{"type":"string","enum":["ok","rejected"],"title":"Status","description":"'ok' — provider accepted credentials and key is now stored. 'rejected' — provider rejected credentials; nothing was written."},"provider":{"type":"string","enum":["openrouter_byok","polza_byok","ollama_byok"],"title":"Provider","description":"Provider type that was tested."},"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason","description":"Human-readable rejection reason (masked — never echoes plaintext key). Present only when status='rejected'."},"version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version","description":"Ollama server version string (e.g. '0.5.4'). Only populated for ollama_byok when status='ok'."},"warning":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Warning","description":"Non-fatal advisory message. For ollama_byok: emitted when the base_url points to localhost but Andish is running in managed (remote) mode — in that case the endpoint won't be reachable in production."}},"type":"object","required":["status","provider"],"title":"LlmTestResponse","description":"Response for POST /v1/workspace/llm/test.\n\nOn success the key is persisted; on rejection nothing is written.\nADR-013 §5.3: reason strings are masked to avoid echoing credentials\nor leaking provider-internal error messages.\n\nollama_byok-specific fields:\n  version: Ollama server version string when status='ok' (e.g. '0.5.4').\n  warning: Non-fatal advisory message (e.g. localhost-on-managed warning)."},"LoginRequest":{"properties":{"email":{"type":"string","maxLength":320,"format":"email","title":"Email","description":"Registered email address."},"password":{"type":"string","maxLength":128,"minLength":1,"title":"Password","description":"Plain-text password for verification."}},"type":"object","required":["email","password"],"title":"LoginRequest","description":"Request body for POST /v1/auth/login."},"LoginResponse":{"properties":{"access_token":{"type":"string","title":"Access Token","description":"JWT HS256 access token (TTL 12h)."},"token_type":{"type":"string","title":"Token Type","description":"Always 'bearer'.","default":"bearer"},"user_id":{"type":"string","format":"uuid","title":"User Id","description":"Authenticated user UUID."},"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id","description":"Tenant UUID for this user."}},"type":"object","required":["access_token","user_id","tenant_id"],"title":"LoginResponse","description":"Response for successful POST /v1/auth/login (HTTP 200)."},"LogoutResponse":{"properties":{"revoked":{"type":"boolean","title":"Revoked","description":"Always true on success.","default":true}},"type":"object","title":"LogoutResponse","description":"Response for successful POST /v1/auth/logout (HTTP 200).\n\nWire format: {\"revoked\": true}"},"MeResponse":{"properties":{"user_id":{"type":"string","format":"uuid","title":"User Id"},"email":{"type":"string","title":"Email","description":"User email (CITEXT, returned as-is)."},"email_verified":{"type":"boolean","title":"Email Verified","description":"True if email was verified via confirmation link. MVP: always False (email verification deferred to W2)."},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_login_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Login At"},"tenant":{"$ref":"#/components/schemas/TenantInfo"},"consents":{"$ref":"#/components/schemas/ConsentStatus"}},"type":"object","required":["user_id","email","email_verified","created_at","tenant","consents"],"title":"MeResponse","description":"Response for GET /v1/auth/me (HTTP 200).\n\nReturns authenticated user info + tenant + consent status.\nRequires valid JWT in Authorization: Bearer header."},"NeighborResponse":{"properties":{"entity":{"$ref":"#/components/schemas/EntityRef","description":"The seed entity whose neighbors were requested."},"neighbors":{"items":{"$ref":"#/components/schemas/EntityRef"},"type":"array","title":"Neighbors","description":"Neighboring entity references."},"edges":{"items":{"$ref":"#/components/schemas/EdgeRef"},"type":"array","title":"Edges","description":"Edges incident to the seed entity (hop=1 only)."},"hop":{"type":"integer","maximum":2.0,"minimum":1.0,"title":"Hop","description":"Hop depth that was used."}},"type":"object","required":["entity","neighbors","edges","hop"],"title":"NeighborResponse","description":"200 response for GET /v1/graph/entities/{entity_id}/neighbors."},"PartDetailResponse":{"properties":{"id":{"type":"string","title":"Id","description":"Part document UUID."},"part_index":{"type":"integer","title":"Part Index","description":"0-based position within the parent split (0–3)."},"status":{"type":"string","title":"Status","description":"Part ingestion status (queued/indexed/failed/partial)."},"progress_percent":{"type":"integer","title":"Progress Percent","description":"Per-part extraction progress 0–100."},"error_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message","description":"Last error detail if status=failed; null otherwise."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"Row creation timestamp."},"indexed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Indexed At","description":"Timestamp when status transitioned to indexed; null if not yet indexed."},"split_strategy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Split Strategy","description":"Splitter algorithm used: markdown_headers | paragraph_breaks | token_fallback."}},"type":"object","required":["id","part_index","status","progress_percent","created_at"],"title":"PartDetailResponse","description":"Per-part status record returned by the admin drill-down endpoint.\n\nContent fields are explicitly excluded — admin must not receive plaintext\nor ciphertext via this endpoint (ADR-019 §plaintext-never-logged, ADR-037 §2.9)."},"PartsListResponse":{"properties":{"parent_id":{"type":"string","title":"Parent Id","description":"UUID of the XL parent document."},"parts":{"items":{"$ref":"#/components/schemas/PartDetailResponse"},"type":"array","title":"Parts","description":"Part records ordered by part_index ascending."}},"type":"object","required":["parent_id","parts"],"title":"PartsListResponse","description":"Response for GET /v1/admin/documents/{parent_id}/parts."},"PasswordResetConfirmRequest":{"properties":{"token":{"type":"string","maxLength":256,"minLength":1,"title":"Token","description":"Plaintext reset token received via email link. Sent in POST body only."},"new_password":{"type":"string","maxLength":128,"minLength":10,"title":"New Password","description":"New password. Min 10 chars, max 128 (ADR-019 §7). No composition requirements. Hashed with argon2id before storage."}},"type":"object","required":["token","new_password"],"title":"PasswordResetConfirmRequest","description":"Request body for POST /v1/auth/password-reset/confirm.\n\nAC #8: password policy ADR-019 §7 — min 10, max 128, no composition rules.\nI1 (STRIDE): token only in POST body, never in path or query params."},"PasswordResetConfirmResponse":{"properties":{"message":{"type":"string","title":"Message","description":"Confirmation that the password was changed.","default":"Password has been reset successfully. Please log in with your new password."}},"type":"object","title":"PasswordResetConfirmResponse","description":"Response for successful POST /v1/auth/password-reset/confirm (HTTP 200)."},"PasswordResetRequestRequest":{"properties":{"email":{"type":"string","maxLength":320,"format":"email","title":"Email","description":"Email address to send the password-reset link to."}},"type":"object","required":["email"],"title":"PasswordResetRequestRequest","description":"Request body for POST /v1/auth/password-reset/request.\n\nAC #1/#3: always returns uniform 200 response regardless of email existence."},"PasswordResetRequestResponse":{"properties":{"message":{"type":"string","title":"Message","description":"Uniform message — does not reveal whether the email exists.","default":"If this email is registered, you will receive a reset link shortly."}},"type":"object","title":"PasswordResetRequestResponse","description":"Response for POST /v1/auth/password-reset/request (always HTTP 200).\n\nUniform response regardless of whether the email exists (AC #1, #3 — anti-enumeration)."},"ProfileResponse":{"properties":{"user_id":{"type":"string","format":"uuid","title":"User Id"},"email":{"type":"string","title":"Email","description":"User email address."},"email_verified":{"type":"boolean","title":"Email Verified","description":"True if email was confirmed. MVP: False until the user clicks the verification link."},"last_login_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Login At"},"tenant":{"$ref":"#/components/schemas/TenantInfo"},"role":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Role","description":"Caller's role in their workspace: admin | editor | viewer. None means the role is unknown (a request made with an API key that has no resolvable creator)."}},"type":"object","required":["user_id","email","email_verified","tenant"],"title":"ProfileResponse","description":"Response for GET /v1/auth/profile (HTTP 200).\n\nNarrow shape for the UI banner. Accepts either a dashboard session\ntoken or an API key — see the endpoint description for the auth\ndetail; the difference from MeResponse is this response's shape\n(no consent fields, no created_at), not the auth method.\n\nEvery field describes the CALLER (Task A3, ADR-074): user_id, email,\nemail_verified, last_login_at and role are all resolved via ctx.user_id\n— the identity behind this specific request — never via\ntenant.owner_user_id. A workspace can contain more than one person\n(ADR-072), and each of them gets back their own row."},"QueryContext":{"properties":{"chunks":{"items":{"$ref":"#/components/schemas/ContextChunk"},"type":"array","title":"Chunks"},"entities":{"items":{"$ref":"#/components/schemas/ContextEntity"},"type":"array","title":"Entities"},"relations":{"items":{"$ref":"#/components/schemas/ContextRelation"},"type":"array","title":"Relations"},"truncated":{"type":"boolean","title":"Truncated","default":false}},"type":"object","required":["chunks","entities","relations"],"title":"QueryContext","description":"Structured retrieval payload for context_only=True mode (AC-2, AC-9)."},"QueryMetadata":{"properties":{"mode":{"type":"string","title":"Mode"},"model":{"type":"string","title":"Model"},"tokens_in":{"type":"integer","title":"Tokens In"},"tokens_out":{"type":"integer","title":"Tokens Out"},"latency_ms":{"type":"integer","title":"Latency Ms"},"unresolved_sources_count":{"type":"integer","title":"Unresolved Sources Count","default":0},"no_answer_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"No Answer Reason"}},"type":"object","required":["mode","model","tokens_in","tokens_out","latency_ms"],"title":"QueryMetadata","description":"Per-request telemetry included in every QueryResponse."},"QueryRequest":{"properties":{"query":{"type":"string","maxLength":2000,"minLength":3,"title":"Query","description":"Natural-language question to ask the knowledge graph."},"mode":{"type":"string","enum":["mix","local","global","naive"],"title":"Mode","description":"Retrieval mode. ``mix`` (default) combines knowledge graph + vector search. ``local`` / ``global`` / ``naive`` are x-experimental and may return an empty answer when the graph is sparse.","default":"mix"},"include_sources":{"type":"boolean","title":"Include Sources","description":"When true, the response includes best-effort source references.","default":true},"context_only":{"type":"boolean","title":"Context Only","description":"When true, retrieval is performed but the synthesis LLM is NOT called. The response includes a ``context`` object (chunks, entities, relations) instead of a synthesised ``answer``.  Use this mode to let your own LLM (e.g. Claude, GPT-4) synthesise the answer from raw retrieval data, which avoids paying for double synthesis.\n\n**API-key requests (ADR-082):** this flag is IGNORED for the synthesis decision — a question authenticated with an API key never reaches our model at all, regardless of this value. It only controls the *shape* of ``answer``: ``true`` keeps it empty (use ``context``); ``false`` fills it with the same retrieved material rendered as text, led by a mandatory disclaimer, for legacy clients that expect a ready answer. Only a JWT (dashboard session) request can get a real synthesised answer.","default":false},"hl_keywords":{"anyOf":[{"items":{"type":"string","maxLength":200},"type":"array","maxItems":100},{"type":"null"}],"title":"Hl Keywords","description":"High-level (abstract) keywords for the A2 retrieval path. When BOTH hl_keywords and ll_keywords are provided, the platform skips keyword-extraction LLM call (Gemini), making this request completely free of platform LLM cost.  Must be provided together with ll_keywords — one without the other is a 422 error.\n\n**API-key requests:** with both lists, the graph is searched with them directly; without either, the request falls back to vector-only (``naive``) search. Either way, an API-key question never calls our model (ADR-082)."},"ll_keywords":{"anyOf":[{"items":{"type":"string","maxLength":200},"type":"array","maxItems":100},{"type":"null"}],"title":"Ll Keywords","description":"Low-level (specific) keywords for the A2 retrieval path. See hl_keywords for full description."}},"type":"object","required":["query"],"title":"QueryRequest","description":"Request body for POST /v1/query.\n\nModes local / global / naive are experimental — marked x-experimental\nin OpenAPI description.  mix is the recommended production mode."},"QueryResponse":{"properties":{"answer":{"type":"string","title":"Answer"},"sources":{"items":{"$ref":"#/components/schemas/SourceItemResponse"},"type":"array","title":"Sources"},"metadata":{"$ref":"#/components/schemas/QueryMetadata"},"context":{"anyOf":[{"$ref":"#/components/schemas/QueryContext"},{"type":"null"}]}},"type":"object","required":["answer","sources","metadata"],"title":"QueryResponse","description":"200 response for POST /v1/query.\n\nIn synthesis mode (context_only=False): answer is filled, context is None.\nIn context mode (context_only=True): answer is empty string, context is filled.\n\nWire-contract evolution follows ADR-012 §Stability Contract."},"RegisterRequest":{"properties":{"email":{"type":"string","maxLength":320,"format":"email","title":"Email","description":"User email address. Stored as CITEXT (case-insensitive unique)."},"password":{"type":"string","maxLength":128,"minLength":10,"title":"Password","description":"Plain-text password, hashed with argon2id before storage. Min 10 chars (OWASP 2023 baseline). No composition requirements."},"confirm_password":{"type":"string","maxLength":128,"minLength":10,"title":"Confirm Password","description":"Must match password. Validated at Pydantic layer, not stored."},"workspace_name":{"anyOf":[{"type":"string","maxLength":100,"minLength":1},{"type":"null"}],"title":"Workspace Name","description":"Optional workspace display name. Defaults to the email address if not provided."},"agreed_terms_v1":{"type":"boolean","title":"Agreed Terms V1","description":"User accepted Terms of Service v1. Must be True."},"agreed_no_pdn":{"type":"boolean","title":"Agreed No Pdn","description":"User acknowledges they will NOT upload third-party personal data (PII) per ФЗ-152 Part В. Must be True."},"agreed_processing_metadata":{"type":"boolean","title":"Agreed Processing Metadata","description":"User consents to processing of their content metadata per ФЗ-152 Part А. Must be True."},"agreed_crossborder_llm":{"type":"boolean","title":"Agreed Crossborder Llm","description":"Separate cross-border transfer flag of consent set v1. Optional since consent-register-v2-2026-09-27: defaults to false, stored as sent, not validated at registration.","default":false}},"type":"object","required":["email","password","confirm_password","agreed_terms_v1","agreed_no_pdn","agreed_processing_metadata"],"title":"RegisterRequest","description":"Request body for POST /v1/auth/register.\n\nagreed_terms_v1, agreed_no_pdn and agreed_processing_metadata are required\nand must be True at the API layer (defence-in-depth alongside the DB CHECK\nconstraint per ADR-018). agreed_crossborder_llm is optional since consent\nset consent-register-v2-2026-09-27.\nAudit-trail fields (signed_ip, user_agent) are populated by the endpoint\nfrom request headers — NOT sent by the client. They are modelled here\nfor clarity of what gets persisted."},"RegisterResponse":{"properties":{"user_id":{"type":"string","format":"uuid","title":"User Id","description":"Newly created user UUID."},"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id","description":"Newly created tenant UUID."},"access_token":{"type":"string","title":"Access Token","description":"JWT HS256 access token (TTL 12h)."},"token_type":{"type":"string","title":"Token Type","description":"Always 'bearer'.","default":"bearer"},"api_key":{"type":"string","title":"Api Key","description":"First api_key in plaintext (adsh_test_* or adsh_live_*). Shown ONCE at registration — the server stores only the hash. Store this securely."},"api_key_id":{"type":"string","format":"uuid","title":"Api Key Id","description":"UUID of the api_key record."},"redirect_url":{"type":"string","title":"Redirect Url","description":"Frontend URL the client should redirect to after registration. Points to the post-registration onboarding flow."}},"type":"object","required":["user_id","tenant_id","access_token","api_key","api_key_id","redirect_url"],"title":"RegisterResponse","description":"Response for successful POST /v1/auth/register (HTTP 201).\n\nReturns the newly created user_id, tenant_id, access_token (JWT),\nand the plaintext api_key (shown ONCE — not stored in plaintext)."},"ResendVerificationRequest":{"properties":{"email":{"type":"string","maxLength":320,"format":"email","title":"Email","description":"Email address to resend the verification link to."}},"type":"object","required":["email"],"title":"ResendVerificationRequest","description":"Request body for POST /v1/auth/resend-verification.\n\nAnti-enumeration: always returns uniform 200 regardless of email existence."},"ResendVerificationResponse":{"properties":{"message":{"type":"string","title":"Message","description":"Uniform message — does not reveal email existence or verification status.","default":"If this email is registered and unverified, you will receive a new verification link shortly."}},"type":"object","title":"ResendVerificationResponse","description":"Response for POST /v1/auth/resend-verification (always HTTP 200).\n\nUniform response regardless of whether the email exists or is already\nverified (anti-enumeration — same as password-reset/request)."},"SourceItemResponse":{"properties":{"document_id":{"type":"string","title":"Document Id"},"chunk_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Chunk Id"},"excerpt":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Excerpt"},"reference_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reference Id"},"document_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Document Date"},"loaded_days_ago":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Loaded Days Ago"},"freshness":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Freshness"},"document_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Document Name"}},"type":"object","required":["document_id"],"title":"SourceItemResponse","description":"Single source document reference.\n\n*Best-effort*: document_id is always present; every other field may be\nNone when the retrieval result did not carry it.  excerpt/reference_id\nare populated per ADR-062 (chat-sources-wiring, 2026-07-29).\n\nWire-contract evolution rules live in\n``docs/technical/decisions.md`` ADR-012 §Stability Contract — read\nthat section before adding, removing, or renaming any field below."},"StreamQueryRequest":{"properties":{"query":{"type":"string","maxLength":2000,"minLength":3,"title":"Query","description":"Natural-language question to ask the knowledge graph."},"mode":{"type":"string","enum":["mix","local","global","naive"],"title":"Mode","description":"Retrieval mode. ``mix`` (default) is recommended for production. ``local`` / ``global`` / ``naive`` are x-experimental.","default":"mix"},"include_sources":{"type":"boolean","title":"Include Sources","description":"When true, source chunks are emitted after the last token.","default":true}},"type":"object","required":["query"],"title":"StreamQueryRequest","description":"Request body for POST /v1/query/stream.\n\nMirrors QueryRequest from query.py; stream: true is implicit — this\nendpoint always streams."},"TenantInfo":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"slug":{"type":"string","title":"Slug"},"name":{"type":"string","title":"Name"},"plan":{"type":"string","title":"Plan"},"tier":{"type":"string","title":"Tier","description":"Billing tier.","default":"free"},"trial_ends_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Trial Ends At","description":"Deprecated (ADR-083): the free tier has no end date, so this is null for every tenant that was not on an old trial. Kept in the schema until the next API version (ADR-012)."},"trial_days_remaining":{"type":"integer","title":"Trial Days Remaining","description":"Days remaining in the trial period; 0 if expired or not on trial.","readOnly":true}},"type":"object","required":["id","slug","name","plan","trial_days_remaining"],"title":"TenantInfo","description":"Partial tenant info embedded in MeResponse and ProfileResponse."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VerifyEmailRequest":{"properties":{"token":{"type":"string","maxLength":256,"minLength":1,"title":"Token","description":"Plaintext verification token received via email."}},"type":"object","required":["token"],"title":"VerifyEmailRequest","description":"Request body for POST /v1/auth/verify-email."},"VerifyEmailResponse":{"properties":{"user_id":{"type":"string","format":"uuid","title":"User Id","description":"UUID of the verified user."},"email":{"type":"string","title":"Email","description":"Verified email address."},"email_verified_at":{"type":"string","format":"date-time","title":"Email Verified At","description":"Timestamp when email was verified (UTC)."},"message":{"type":"string","title":"Message","default":"Email verified successfully."}},"type":"object","required":["user_id","email","email_verified_at"],"title":"VerifyEmailResponse","description":"Response for successful POST /v1/auth/verify-email (HTTP 200)."},"WaitlistConfirmResponse":{"properties":{"status":{"type":"string","const":"confirmed","title":"Status","default":"confirmed"},"message":{"type":"string","title":"Message","default":"Your email has been confirmed. We'll notify you at launch."}},"type":"object","title":"WaitlistConfirmResponse","description":"Response for successful email confirmation."},"WaitlistSignupRequest":{"properties":{"email":{"type":"string","maxLength":320,"format":"email","title":"Email","description":"Signup email address (normalised to lowercase)."},"name":{"anyOf":[{"type":"string","maxLength":200,"minLength":1},{"type":"null"}],"title":"Name","description":"Optional contact name."},"company":{"anyOf":[{"type":"string","maxLength":200,"minLength":1},{"type":"null"}],"title":"Company","description":"Optional company name."},"team_size":{"anyOf":[{"type":"string","enum":["5-30","30-200","200+"]},{"type":"null"}],"title":"Team Size","description":"Team size bracket for ICP segmentation."},"icp_role":{"anyOf":[{"type":"string","enum":["cto","tl","dev","other"]},{"type":"null"}],"title":"Icp Role","description":"Submitter role for ICP segmentation."},"utm_source":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Utm Source"},"utm_medium":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Utm Medium"},"utm_campaign":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Utm Campaign"},"consent_fz152":{"type":"boolean","title":"Consent Fz152","description":"Explicit consent to process personal data per FZ-152 (Russian law). Must be True — request is rejected with 422 if False."}},"type":"object","required":["email","consent_fz152"],"title":"WaitlistSignupRequest","description":"Request body for POST /v1/waitlist."},"WaitlistSignupResponse":{"properties":{"status":{"type":"string","const":"pending_confirmation","title":"Status","default":"pending_confirmation"},"message":{"type":"string","title":"Message","default":"Check your email to confirm your waitlist signup."}},"type":"object","title":"WaitlistSignupResponse","description":"Response for 202 Accepted — confirmation email dispatched."},"WaitlistUnsubscribeResponse":{"properties":{"status":{"type":"string","const":"unsubscribed","title":"Status","default":"unsubscribed"},"message":{"type":"string","title":"Message","default":"You have been removed from the waitlist."}},"type":"object","title":"WaitlistUnsubscribeResponse","description":"Response for successful unsubscribe."},"WhoamiResponse":{"properties":{"tenant_id":{"type":"string","title":"Tenant Id"},"tenant_name":{"type":"string","title":"Tenant Name"},"scopes":{"items":{"type":"string"},"type":"array","title":"Scopes"},"api_key_prefix":{"type":"string","title":"Api Key Prefix"}},"type":"object","required":["tenant_id","tenant_name","scopes","api_key_prefix"],"title":"WhoamiResponse","description":"Response shape for GET /v1/whoami."},"WorkspaceDeleteRequest":{"properties":{"confirm_name":{"type":"string","maxLength":255,"minLength":1,"title":"Confirm Name","description":"Must match the workspace's current name exactly."},"current_password":{"type":"string","maxLength":128,"minLength":1,"title":"Current Password","description":"The caller's OWN current password — re-entered as a step-up confirmation for this irreversible action. Never anyone else's."}},"type":"object","required":["confirm_name","current_password"],"title":"WorkspaceDeleteRequest","description":"Request body for DELETE /v1/workspace (Task A4, contract §2.4 + §2.7).\n\nTwo independent confirmations, both required:\n  confirm_name     — exact match against tenants.name (mouse-slip guard;\n                      every member of the workspace already knows this).\n  current_password — the CALLER's own current password, verified against\n                      their own stored hash (SecEng step-up control,\n                      docs/security/stride-team-mode-a4-2026-09-01.md\n                      \"Согласование с контрактом\" точка 1) — a defence\n                      against an already-open/stolen admin session, not\n                      against a promise of undo (there is none, CEO\n                      2026-09-01)."},"WorkspaceDeleteResponse":{"properties":{"deleted_at":{"type":"string","format":"date-time","title":"Deleted At","description":"UTC timestamp when the workspace was soft-deleted."},"data_removal_eta":{"type":"string","title":"Data Removal Eta","description":"Human-readable note on data removal. Personal data for every affected person is wiped immediately and cannot be undone; this field describes only the (not yet scheduled) physical purge of the remaining soft-deleted rows."},"users_affected":{"type":"integer","minimum":0.0,"title":"Users Affected","description":"Number of live people in the workspace whose PII was wiped."}},"type":"object","required":["deleted_at","data_removal_eta","users_affected"],"title":"WorkspaceDeleteResponse","description":"Response for DELETE /v1/workspace (HTTP 200).\n\nCEO 2026-09-01 frame: deletion is immediate and irreversible — there is\nno 30-day cooling-off / undo window. data_removal_eta describes ONLY the\n(currently unscheduled) physical purge of already-wiped rows."},"WorkspaceSettingsPatch":{"properties":{"freshness_threshold_days":{"anyOf":[{"type":"integer","maximum":365.0,"minimum":1.0},{"type":"null"}],"title":"Freshness Threshold Days","description":"Workspace freshness threshold in days. Documents loaded more than this many days ago are marked stale in query responses. Valid range: 1..365. Pass null to reset to platform default (90 days)."}},"type":"object","title":"WorkspaceSettingsPatch","description":"Request body for PATCH /v1/workspace.\n\nPartial update — only fields present in the request body are updated.\nCurrently supports freshness_threshold_days (AC-FRESH-1.6)."},"WorkspaceSettingsResponse":{"properties":{"freshness_threshold_days":{"type":"integer","minimum":1.0,"title":"Freshness Threshold Days","description":"Effective workspace freshness threshold in days (platform default 90 when tenant value is NULL)."}},"type":"object","required":["freshness_threshold_days"],"title":"WorkspaceSettingsResponse","description":"Response for PATCH /v1/workspace.\n\nReturns the persisted settings after update."},"WorkspaceUsageResponse":{"properties":{"period":{"type":"string","title":"Period","description":"ISO year-month for which usage is aggregated (e.g. '2026-04')."},"cost_current_rub":{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$","title":"Cost Current Rub","description":"Sum of cost_rub from usage_events for the current calendar month. Decimal-as-string per ADR-014 §Wire format."},"cost_cap_rub":{"anyOf":[{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"},{"type":"null"}],"title":"Cost Cap Rub","description":"Monthly cost cap in rubles (Decimal-as-string per ADR-014). Null means no cap (Enterprise). For a tier with a monthly ceiling (free: 300 RUB, ADR-083) this is the REAL ceiling the server enforces (402 cost_cap_monthly_exceeded). For every other tier it is the display-only approximation cost_cap_rub_daily × 30 — nothing checks it."},"cost_cap_rub_daily":{"anyOf":[{"type":"string","pattern":"^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"},{"type":"null"}],"title":"Cost Cap Rub Daily","description":"Per-workspace daily cost cap in rubles (Decimal-as-string per ADR-014). Null means no cap (free_byok uses BYOK key — no managed LLM cost; Enterprise is invoiced separately). Non-null for free (30 RUB)/trial/start/team/business tiers."},"percent_used":{"type":"number","minimum":0.0,"title":"Percent Used","description":"cost_current_rub / cost_cap_rub × 100.  0.0 when cap is null (Enterprise).  May exceed 100 when overage."},"documents_uploaded":{"type":"integer","minimum":0.0,"title":"Documents Uploaded","description":"Places the workspace's documents take in the document limit: cards a person sees (simple documents and large documents, a large document counts once, not its parts), not deleted, status != 'failed'. Available immediately after upload — before indexing completes.","default":0},"documents_processed":{"type":"integer","minimum":0.0,"title":"Documents Processed","description":"Count of documents that can already be asked about: cards with status 'indexed', or a large document that is 'partial' with at least one indexed part. A large document counts once. Never above documents_uploaded; reaches it once indexing completes.","default":0},"documents_current":{"type":"integer","minimum":0.0,"title":"Documents Current","description":"Count of completed documents in workspace (same number as documents_processed). Deprecated alias for documents_processed — kept for backward compat until 2026-06-01; use documents_processed instead."},"documents_cap":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Documents Cap","description":"Document count cap for this tier (from TierConfig.docs_cap). Null for Enterprise (unlimited)."},"queries_today":{"type":"integer","minimum":0.0,"title":"Queries Today","description":"Number of queries issued today UTC (Redis counter)."},"queries_cap_daily":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Queries Cap Daily","description":"Daily query cap for this tier (from TierConfig.queries_per_day). Null for Enterprise (unlimited)."},"tier":{"type":"string","title":"Tier","description":"Current tenant billing tier (US-29). One of: free, free_byok, trial, start, team, business, enterprise. ``free`` (ADR-083) has no end date; ``trial`` and ``free_byok`` are legacy values that migration 047 moves to ``free``."},"account_status":{"type":"string","enum":["trial_active","trial_expired","active"],"title":"Account Status","description":"Derived lifecycle state (ADR-058 §1). 'trial_active' — trial running (trial_ends_at in the future). 'trial_expired' — trial ended (or legacy NULL trial_ends_at); blocked unless a key is connected. 'active' — free (no end date, ADR-083), free_byok or any paid tier. Combine with byok_key_set to render the three /plan states without guessing."},"trial_ends_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Trial Ends At","description":"Deprecated (ADR-083): trial expiry of a legacy trial. Null for every tenant on the free tier (no end date). Kept until the next API version (ADR-012)."},"byok_key_set":{"type":"boolean","title":"Byok Key Set","description":"Whether the tenant has its own LLM provider configured (byok_provider IS NOT NULL — includes Ollama BYOK without a key)."},"storage_used_bytes":{"type":"integer","minimum":0.0,"title":"Storage Used Bytes","description":"Live sum of stored document ciphertext bytes (non-failed, non-deleted documents)."},"storage_cap_bytes":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Storage Cap Bytes","description":"Hard storage cap in bytes for this tier (ADR-058 §5; 500 MB for free/trial/free_byok). Null for Enterprise (unlimited)."},"file_size_cap_mb":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"File Size Cap Mb","description":"Per-file upload size cap in MB for this tier (TierConfig.file_size_cap_mb). Null = unlimited (Enterprise)."},"next_reset_at":{"type":"string","format":"date-time","title":"Next Reset At","description":"Next UTC midnight when monthly cost period resets."},"documents_never_queried":{"type":"integer","minimum":0.0,"title":"Documents Never Queried","description":"Count of documents with last_queried_at IS NULL (never cited by any query).","default":0},"documents_quiet_90d":{"type":"integer","minimum":0.0,"title":"Documents Quiet 90D","description":"Count of documents not cited in the last 90 days (last_queried_at < now - 90 days, or never queried).","default":0}},"type":"object","required":["period","cost_current_rub","percent_used","documents_current","queries_today","tier","account_status","byok_key_set","storage_used_bytes","next_reset_at"],"title":"WorkspaceUsageResponse","description":"Response schema for GET /v1/workspace/usage.\n\nUS-29: documents_cap and queries_cap_daily are now populated from the\ntenant's TierConfig.  tier reflects the real tenant.tier value.\n\nMoney fields (cost_current_rub, cost_cap_rub, cost_cap_rub_daily) are\nDecimal typed. Pydantic V2 serializes Decimal to JSON as string by default,\nsatisfying ADR-014 §Wire format (Decimal-as-string)."}}}}