{"openapi":"3.1.0","info":{"title":"Course Supporter","description":"AI-powered course structuring from learning materials","version":"0.1.0"},"paths":{"/health":{"get":{"summary":"Health","description":"Deep health check — verifies DB and S3 connectivity.","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/nodes":{"post":{"tags":["nodes"],"summary":"Create Root Node","description":"Create a root node (course) in the material tree.\n\nRoot nodes have no parent and appear at the top level.\nThe ``order`` is auto-assigned as the next available position.\n\n``default_language`` is required — see ``RootNodeCreateRequest``.\nThe validator normalizes any standard input form to canonical ISO 639-3\nbefore the DB write.","operationId":"create_root_node_api_v1_nodes_post","security":[{"APIKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RootNodeCreateRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["nodes"],"summary":"List Root Nodes","description":"List root nodes (courses) for the authenticated tenant.\n\nReturns a paginated list sorted by creation date (newest first).","operationId":"list_root_nodes_api_v1_nodes_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of root nodes to return (1-100).","default":20,"title":"Limit"},"description":"Maximum number of root nodes to return (1-100)."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Number of root nodes to skip for pagination.","default":0,"title":"Offset"},"description":"Number of root nodes to skip for pagination."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/children":{"post":{"tags":["nodes"],"summary":"Create Child Node","description":"Create a child node under an existing parent.\n\nThe child inherits the tenant from the parent. The ``order``\nis auto-assigned as the next available position among siblings.","operationId":"create_child_node_api_v1_nodes__node_id__children_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChildNodeCreateRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/tree":{"get":{"tags":["nodes"],"summary":"Get Tree","description":"Get the full subtree rooted at a node.\n\nReturns all nodes in a nested structure, with children\nrecursively populated. Each level is sorted by ``order``.","operationId":"get_tree_api_v1_nodes__node_id__tree_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/NodeTreeResponse"},"title":"Response Get Tree Api V1 Nodes  Node Id  Tree Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/detail":{"get":{"tags":["nodes"],"summary":"Get Node Detail","description":"Get the full subtree with materials attached to each node.\n\nReturns the hierarchical view including materials at each level\nand their lifecycle states. Includes a course-level fingerprint.","operationId":"get_node_detail_api_v1_nodes__node_id__detail_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeWithDocumentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}":{"get":{"tags":["nodes"],"summary":"Get Node","description":"Get a single node by ID.\n\nReturns the flat node representation without children.\nUse ``GET /nodes/{id}/tree`` for the nested tree.","operationId":"get_node_api_v1_nodes__node_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["nodes"],"summary":"Update Node","description":"Update a node's title, description, and/or default language.\n\nOnly fields present in the request body are updated.\nTo clear a field, send it as ``null``. Updating\n``default_language`` is a pure DB write — it does not trigger\nre-ingestion of existing materials. **Root nodes** cannot have\n``default_language`` cleared — the request is rejected with 422\nso the CHECK constraint never fires as a 500.","operationId":"update_node_api_v1_nodes__node_id__patch","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["nodes"],"summary":"Delete Node","description":"KD3 soft-delete the node and its full subtree (vision §3 KD3).\n\nCascade flow per Phase 1 KD3 adoption:\n\n1. Verify the node exists and belongs to the caller's tenant\n   (404 otherwise — same shape as the rest of the route).\n2. Walk the subtree via ``get_subtree(include_documents=True,\n   tenant_id=...)`` and collect S3 keys from every descendant\n   AuthoredDocument's ``source_url`` BEFORE cascade fires.\n   Order matters: the cascade engine dispatches\n   ``scrub_authored_document`` per descendant which sets\n   ``source_url = ''`` — collecting keys after cascade would\n   miss them all.\n3. Issue ``CascadeDeleteService.soft_delete_with_cascade`` rooted\n   at the node. The engine drives the four-phase hook chain\n   (cancel → invalidate → scrub → write deleted_at → flush) plus\n   per-victim scrub dispatch:\n\n   * ``on_cancel_jobs`` — direct-bind to\n     :class:`JobCancellationService.cancel_jobs_for_entities`\n     per vision §KD13. The cascade traverses\n     ``CourseNode → CourseNode → AuthoredDocument``, so the victim\n     list is heterotypic — it contains every subtree CourseNode id\n     AND every subtree AuthoredDocument id. L1b keys cancellation on\n     ``Job.subject_id IN victim_ids``: node_summary jobs (subject =\n     the vertex CourseNode) and document_processing jobs (subject =\n     the AuthoredDocument) are BOTH matched because their subject ids\n     are in that list (H-L1b-4 Case A). No closure augmentation — the\n     former ``course_node_id``-injection hooks were removed in L1b now\n     that the subject id is a first-class column. KD13 cancel\n     semantics: ``status='cancelled'`` + ``completed_at = now()``;\n     ``deleted_at`` REMAINS NULL (Job ∉ any\n     ``__cascades_soft_delete_to__`` chain per PHASE.md §1.2 audit).\n   * ``on_invalidate_hashes`` — Gap 3 hook bridging\n     :class:`ContentHashService.invalidate_subtree` so parent-hash\n     recompute treats victims as already gone.\n   * Class-level ``__scrub_callable__`` dispatch (models-fix-3)\n     clears KD3 fields per type: CourseNode →\n     ``scrub_course_node`` (``title`` + ``description`` ← KD3\n     marker per hotfix-4); AuthoredDocument →\n     ``scrub_authored_document`` (``filename`` + ``source_url`` ←\n     KD3 marker).\n4. Hand off to ``enqueue_s3_cleanup`` — helper persists a\n   ``s3_cleanup`` Job row, commits cascade + Job atomically\n   (QQ5 boundary), then dispatches the ARQ task post-commit\n   and records the resolved ``arq_job_id``. Empty key list\n   short-circuits — no Job row, no ARQ enqueue.","operationId":"delete_node_api_v1_nodes__node_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/move":{"post":{"tags":["nodes"],"summary":"Move Node","description":"Move a node to a new parent (or to root).\n\nCycle detection is enforced. Set ``parent_id`` to ``null``\nto make the node a root. Returns 422 if the move would create a cycle.","operationId":"move_node_api_v1_nodes__node_id__move_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeMoveRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/reorder":{"post":{"tags":["nodes"],"summary":"Reorder Node","description":"Reorder a node among its siblings.\n\nThe target ``order`` is 0-based. If the value exceeds the\nnumber of siblings, it is clamped to the last position.","operationId":"reorder_node_api_v1_nodes__node_id__reorder_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeReorderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/summary/generate":{"post":{"tags":["node-summaries"],"summary":"Generate Node Summary","description":"Trigger the methodist two-pass regeneration via ARQ (KD10 + KD13).\n\nTwo-step path:\n\n1. **Read-only scope validation (K1 ratify — the 422 API surface).**\n   Constructs the orchestrator via the shared\n   :func:`build_node_summary_orchestrator` DI helper and calls\n   :meth:`validate_scope` (purely read-only — selects + hash\n   comparison; no LLM hook invoked). When the run would leave\n   ``uncovered_stale_node_ids`` outside the vertex's subtree and\n   ``force = false``, returns 422 with the list of uncovered ids\n   so the caller can either narrow scope or retry with\n   ``force = true``.\n2. **Enqueue.** Mirrors ``enqueue_ingestion``: persists a\n   ``node_summary_regeneration`` Job, enqueues the ARQ task,\n   records ``arq_job_id``, commits, returns the queued Job.\n\nInv #4 (clarified): routes never call the orchestrator's\n**generation** (``orch.run()``); the read-only ``validate_scope``\nis the explicit K1-ratified 422 surface and IS permitted here.\nThe orchestrator's session is the route's request-scoped session,\nso the scope check participates in the same transaction view as\nthe subsequent enqueue.\n\nErrors:\n* 404 generic — node missing or owned by another tenant.\n* 422 — ``uncovered_stale_node_ids`` non-empty and ``force=false``.","operationId":"generate_node_summary_api_v1_nodes__node_id__summary_generate_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSummaryGenerateRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/summary":{"get":{"tags":["node-summaries"],"summary":"Get Node Summary","description":"P6 GET — two-step contract per §1910.\n\n* Step 1: foreign tenant or missing node → generic 404\n  ``Node not found`` (rule #12).\n* Step 2: node found, Raw absent → 404 ``not_yet_generated``.\n* Step 3: Raw present → 200 with the full Final contract.\n\nGate is purely Raw-existence (operator-ratified Phase 3.2.4\npre-flight question (b)). ``is_manual`` empty-leaf-without-Raw\nbranch is a forward-compat dead path in 3.2.4 backend (no API\ncreates such Finals).","operationId":"get_node_summary_api_v1_nodes__node_id__summary_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSummaryFinalResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-summaries/{node_id}/final":{"patch":{"tags":["node-summaries"],"summary":"Patch Node Summary Final","description":"Channel 2 — author manual edit of the KD11 §1043-1054 family.\n\nTwo-level validation:\n\n1. **Request schema (first line of defence).** Pydantic's\n   ``extra='forbid'`` on :class:`NodeSummaryFinalUpdateRequest`\n   rejects non-editable keys with 422 at the boundary\n   (``main_concepts`` / ``enclosing_context`` / ``content_hash`` /\n   any meta field / ``is_manual`` / ``manual_description``).\n2. **Repository ``ValueError`` (deep gate).** Belt-and-suspenders\n   guard against a future schema relaxation; surfaces as 422 here.\n\n``approved_at`` and ``is_manual`` are NOT touched (KD11: edits do\nnot auto-invalidate approvals; ``is_manual`` is forward-compat\nonly).","operationId":"patch_node_summary_final_api_v1_node_summaries__node_id__final_patch","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSummaryFinalUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSummaryFinalResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-summaries/{node_id}/final/approve":{"post":{"tags":["node-summaries"],"summary":"Approve Node Summary Final","description":"Explicit approve — sets ``approved_at = now()`` + hard-delete snapshot.","operationId":"approve_node_summary_final_api_v1_node_summaries__node_id__final_approve_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSummaryFinalResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-summaries/{node_id}/final/accept-raw":{"post":{"tags":["node-summaries"],"summary":"Accept Raw Node Summary Final","description":"Implicit-approve flavour of channel 1: overwrite Final from Raw + approve.","operationId":"accept_raw_node_summary_final_api_v1_node_summaries__node_id__final_accept_raw_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSummaryFinalResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-summaries/{node_id}/edit-view":{"get":{"tags":["node-summaries"],"summary":"Get Node Summary Edit View","description":"Combined edit-view per KD11 §1086-1101 (final + raw_observations + snapshot).","operationId":"get_node_summary_edit_view_api_v1_node_summaries__node_id__edit_view_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSummaryEditViewResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/documents":{"post":{"tags":["documents"],"summary":"Create Document","description":"Add a new authored document to a tree node.\n\nAccepts either a URL or a file upload. If a file is provided,\nit is uploaded to S3/MinIO and the resulting URL is stored.\n\nCreates an ``AuthoredDocument`` and auto-enqueues an ingestion job\nvia ARQ. The ``job_id`` in the response can be used to track\nprocessing status via ``GET /api/v1/jobs/{job_id}``.\n\nA YAML file with ``task_type=test`` is a test written in the system (task\n07b): its draft, no storage write, no job, ``job_id: null``; any other file\nor a link with that task type is 422 ``TEST_FILE_NOT_YAML``. A request\nnaming ``source_type=test_object`` is 422 ``TEST_OBJECT_SOURCE_RESERVED``.","operationId":"create_document_api_v1_nodes__node_id__documents_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_create_document_api_v1_nodes__node_id__documents_post"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthoredDocumentCreateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["documents"],"summary":"List Documents","description":"List all authored documents attached to a tree node.\n\nReturns documents ordered by their position (``order`` field).","operationId":"list_documents_api_v1_nodes__node_id__documents_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AuthoredDocumentResponse"},"title":"Response List Documents Api V1 Nodes  Node Id  Documents Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/documents/upload-url":{"post":{"tags":["documents"],"summary":"Get Upload Url","description":"Generate a presigned URL for direct S3 upload.\n\nThe client should PUT the file content to the returned URL,\nthen call ``POST /nodes/{nid}/documents/confirm-upload`` with\nthe returned key. ``source_type=test_object`` is 422\n``TEST_OBJECT_SOURCE_RESERVED`` (task 07b).","operationId":"get_upload_url_api_v1_nodes__node_id__documents_upload_url_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUrlRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUrlResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/documents/confirm-upload":{"post":{"tags":["documents"],"summary":"Confirm Upload","description":"Confirm a presigned upload and create the AuthoredDocument.\n\nVerifies the file exists in S3, creates the database entry,\nand enqueues ingestion. A test is not confirmed here (task 07b): the\nupload is already stored, and a test's YAML is not — 422\n``TEST_FILE_NOT_YAML``, and ``TEST_OBJECT_SOURCE_RESERVED`` for a request\nnaming ``source_type=test_object``, both before storage is read.","operationId":"confirm_upload_api_v1_nodes__node_id__documents_confirm_upload_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfirmUploadRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthoredDocumentCreateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/base":{"post":{"tags":["documents"],"summary":"Attach Project Base","description":"Attach a base archive to a project task (KD18 P2).\n\nThe document must be ``task_type='project'`` (else 422). The archive is\nstored raw to S3 and a new append-only ``ProjectBase`` version is created\n(``pending``) + a deterministic normalization job enqueued — 202, the\nnormalization runs asynchronously.\n\nSecurity context (Decision 1, 1A): the mandatory structural sandbox is\n``extract_archive_safely`` (classify) inside ``normalize_archive``, run by\nthe ARQ job — the fail-closed ``run_stage1`` archive branch is deliberately\nNOT on this path (it would reject a real project's non-allowlisted files).\nThe HTTP gate here is light: an archive-kind extension + a streamed size cap.","operationId":"attach_project_base_api_v1_documents__document_id__base_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_attach_project_base_api_v1_documents__document_id__base_post"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectBaseAttachResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["documents"],"summary":"Get Project Base State","description":"The latest base version's state for author monitoring (KD18 P6).\n\nReturns the LATEST version REGARDLESS of state (``get_latest`` — NOT\n``get_latest_ready``) so a freshly re-uploaded ``pending`` / ``failed`` v2\nis not masked by an older READY v1: the author is monitoring THIS upload's\nnormalization, the opposite goal of the mode-1 student descriptor\n(``GET /homework/tasks/{id}/base``, which needs a usable READY base). This\nis the only author-facing surface carrying ``failure_reason`` — the manifest\nroute is READY-only, so ``pending`` / ``failed`` states render from here.\n404 if the document has no base at all.","operationId":"get_project_base_state_api_v1_documents__document_id__base_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectBaseStateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/base/manifest":{"get":{"tags":["documents"],"summary":"Get Project Base Manifest","description":"The latest READY base version's manifest (KD18 P2, author-only).\n\nReturns the algorithmic manifest exactly as the base-normalize worker stored\nit (``schema`` / ``aggregate_hash`` / ``included`` / ``excluded`` /\n``total_files`` / ``total_bytes``). ``PrepDep`` (author) — NOT the mode-1\nAPI-key ``GET /base``, because the manifest is the full file listing. 404 if\nthe document has no READY base (a pending / failed version carries no\nmanifest).\n\nDD-6-V: typed as the P1 ``Manifest`` dataclass (reconstructed from JSONB via\nthe reviver) instead of an opaque ``dict[str, Any]`` — a single source of\ntruth for the FE consumer (P6). The wire-shape is unchanged: FastAPI\nserializes the frozen dataclass byte-identically to the stored JSONB.","operationId":"get_project_base_manifest_api_v1_documents__document_id__base_manifest_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectBaseManifestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/structure":{"get":{"tags":["documents"],"summary":"Get Document Structure","description":"What a code material's processing left out, and why (author-only).\n\nCloses the post-hoc half of R5 visibility: the upload dialog warns before\nprocessing, but processing finishes hours later, and until now nothing\ntold the author afterwards which of their files never reached the model.\n\n``PrepDep`` rather than the shared author/check dependency, matching\n``GET /base/manifest`` above for the same reason it gave: a full file\nlisting of the author's material is not the checking key's business.\n\nA separate route rather than a field on the document: ``structure`` lives\non ``DocumentSummary``, and the two listing paths above are hot and would\ncarry a column that is NULL for every non-code document.\n\n404 — the same generic one an unknown document gets — when there is no\nstructure to show: a non-code material (the column is NULL for all of\nthem), or a code material still being processed. An empty 200 would say\n\"read in full\", which is a different and wrong answer. A code material\nthat really was read in full answers 200 with two empty lists.","operationId":"get_document_structure_api_v1_documents__document_id__structure_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentStructureResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}":{"get":{"tags":["documents"],"summary":"Get Document","description":"Get a single authored document by ID.\n\nVerified through the node → tenant chain.","operationId":"get_document_api_v1_documents__document_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthoredDocumentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["documents"],"summary":"Update Document","description":"Update document metadata (material_role and/or task_type).\n\nOnly fields explicitly sent in the request body are updated.\nPass ``task_type: null`` to clear the task flag; omit the field\nto keep the current value.\n\nA test written in the system keeps its kind and its role, and a refused\nrequest changes nothing: a ``task_type`` other than ``test`` is 422\n``TEST_OBJECT_TYPE_FIXED``, a ``material_role`` other than\n``educational`` is 422 ``TEST_OBJECT_ROLE_FIXED`` (task 07c). No other\nmaterial becomes a test by ``task_type: test`` — 422 ``TEST_IS_AN_OBJECT``.","operationId":"update_document_api_v1_documents__document_id__patch","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthoredDocumentUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthoredDocumentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["documents"],"summary":"Delete Document","description":"KD3 soft-delete the document and any descendants (vision §3 KD3).\n\nCascade flow per Phase 1 KD3 adoption — mirrors commit (k)\n``delete_node`` shape with the cascade rooted at AuthoredDocument\ninstead of CourseNode:\n\n1. Verify the document exists and belongs to the caller's tenant\n   (404 otherwise — same shape as the rest of the route).\n2. Extract the S3 key from ``document.source_url`` BEFORE cascade\n   fires. Order matters: the cascade engine dispatches\n   ``scrub_authored_document`` which sets ``source_url = ''`` —\n   collecting the key after cascade would miss it.\n3. Issue ``CascadeDeleteService.soft_delete_with_cascade`` rooted\n   at the document. The engine drives the four-phase hook chain\n   (cancel → invalidate → scrub → write deleted_at → flush) plus\n   per-victim scrub dispatch:\n\n   * ``on_cancel_jobs`` — direct binding to\n     :class:`JobCancellationService.cancel_jobs_for_entities`\n     (L1b). The cascade victim list carries the document's own id,\n     which IS the job subject, and JCS now keys on\n     ``Job.subject_id IN victim_ids`` — so the document's ingestion\n     job is cancelled precisely, with no over-cancel of sibling\n     jobs and no course_node_id augmentation. KD13 cancel semantics:\n     ``status='cancelled'`` + ``completed_at = now()``;\n     ``deleted_at`` REMAINS NULL — Job is subject-cancelled, not\n     cascade soft-deleted (per PHASE.md §1.2 audit: Job ∉ any\n     ``__cascades_soft_delete_to__`` chain).\n   * ``on_invalidate_hashes`` — Gap 3 hook bridging\n     :class:`ContentHashService.invalidate_subtree` so parent-\n     CourseNode ``content_hash`` recompute treats the document as\n     already gone. Closes a tangential gap in commit (l) where\n     this hook was omitted entirely (cf. ``nodes.py`` +\n     ``storage.py`` mirror sites — hotfix-5 ratified per rule #13\n     atomicity).\n   * Class-level ``__scrub_callable__`` (AuthoredDocument →\n     ``scrub_authored_document`` emitting KD3 marker per\n     hotfix-4) clears ``filename`` + ``source_url``.\n     DocumentSummary + DocumentSegment descendants are no-op in\n     Phase 1 — Amendment 16 defers their scrub callables to\n     Phase 2.x and pipeline does not write to these tables yet,\n     so cascade traversal through them yields zero victims.\n4. Hand off to ``enqueue_s3_cleanup`` — helper persists a\n   ``s3_cleanup`` Job row, commits cascade + Job atomically\n   (QQ5 boundary), then dispatches the ARQ task post-commit and\n   records the resolved ``arq_job_id``. Empty key list (external\n   URL or already-scrubbed source_url) short-circuits — no Job\n   row, no ARQ enqueue.","operationId":"delete_document_api_v1_documents__document_id__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/retry":{"post":{"tags":["documents"],"summary":"Retry Document","description":"Retry ingestion for a document.\n\nBy default only documents in ``error`` state can be retried.\nPass ``?force=true`` to re-ingest from any state (e.g. to\nreprocess a ``ready`` document after pipeline improvements).\nReturns 409 if the document is not retryable without ``force``.\nReturns 410 Gone (vision §6 QQ6) if the document is soft-deleted —\nretry on a soft-deleted target is permanently unavailable, never\ntransient.","operationId":"retry_document_api_v1_documents__document_id__retry_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}},{"name":"force","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthoredDocumentCreateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/file-roles":{"post":{"tags":["documents"],"summary":"Confirm File Roles","description":"Confirm the author's file-role decision, then enqueue processing (№21).\n\nFlow point 4: validate against the current proposal → persist the decision\n(proposal byte-untouched, invariant I1) → enqueue DOCUMENT_PROCESSING. The\nenqueue mirrors :func:`retry_document` — supersede any in-flight job of this\nsubject (a no-op in the normal ``awaiting_author`` state), then enqueue; a\nconcurrent collision surfaces as 409, never a 500 from the idempotency index.\n\nValidation:\n\n* ``tree_digest`` must equal the current ``proposal.tree_digest`` — else 409\n  ``file_roles_stale`` (the tree changed, or there is no proposal; the client\n  re-reads the document and re-renders the screen).\n* ``files`` keys must EXACTLY cover ``proposal.files`` — a missing or extra\n  path is a 422 (the UI expands folder gestures to per-file states, so a full\n  cover is expected; decision 3 / decision 4).\n* role tokens are validated by the request schema (``Literal`` — a 422 on an\n  unknown token; ``auxiliary`` is legal, decision 2).","operationId":"confirm_file_roles_api_v1_documents__document_id__file_roles_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileRolesDecisionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthoredDocumentCreateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/reference":{"get":{"tags":["references"],"summary":"Get Reference","description":"The state of this task's key, its answers and its explanations.\n\nA task with no key answers **200** with ``status = \"awaiting_key\"``, not\n404: the absence of a key is a state of the task, and an author who has not\nwritten one yet has not asked for something missing.\n\nThe read is not free of consequences, and that is deliberate (the lazy path\nof ``reference_service``): if the test text changed and its question\nnumbers did not, this call carries the author's answers onto the new\nversion and asks for their explanations. When another job of the task is\nin flight, that request cannot be made: **409** ``GENERATION_IN_PROGRESS``,\nand the carrying is undone with it.","operationId":"get_reference_api_v1_documents__document_id__reference_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReferenceViewResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/reference/override":{"put":{"tags":["references"],"summary":"Put Reference Override","description":"Replace the author's key whole; generate its explanations once.\n\nThe last replacement wins — an upsert, not a conflict (ratified\n2026-09-19). The response carries the stored layer, so the author sees what\nactually stands rather than what they sent.\n\nSending the same answers again costs nothing: the version for that key\nalready exists, so no new work is asked for, and the response reports its\ncurrent state.\n\nA key that needs a new generation, sent while another job of the task is\nin flight — the previous key's generation, or the task's own processing —\nanswers **409** ``GENERATION_IN_PROGRESS``, and nothing is stored: not the\nkey, not its version (task 07, decision 9).","operationId":"put_reference_override_api_v1_documents__document_id__reference_override_put","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReferenceKeyUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReferenceViewResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["references"],"summary":"Delete Reference Override","description":"Clear the author's key; the task goes back to awaiting one.\n\n**200**, not 204, and with the same body the other two routes return: the\nauthor's next question after clearing a key is \"what is the state now\", and\nanswering it here saves a second call. Clearing a task that has no key is\nnot an error — the state it asks for is the state that results.\n\nThe generated versions stay. They cost money and they belong to the keys\nthat produced them, so sending the same key again finds its explanations\nwaiting rather than paying for them twice.","operationId":"delete_reference_override_api_v1_documents__document_id__reference_override_delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReferenceViewResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/criteria":{"get":{"tags":["criteria"],"summary":"Get Criteria","description":"The criteria of the task's current version: each layer apart.\n\nA task whose list is not composed yet answers **200**, not 404: no route\ncomposes a list — the first submission of a student's work does\n(``TASK.md`` section 9, decision 1). The status says why there is none —\n``awaiting_first_submission``, ``composing`` or ``not_composed`` with its\n``reason_code`` — beside how many works wait and a message saying what the\nauthor can do (task 09b). Reading writes nothing.","operationId":"get_criteria_api_v1_documents__document_id__criteria_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaViewResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/criteria/override":{"put":{"tags":["criteria"],"summary":"Put Criteria Override","description":"Replace the author's edit whole; it is in force for this task version.\n\nThe last replacement wins, and the edit it replaced stays as a snapshot. A\ncriterion or a point sent with its id keeps it; one sent without gets a\nnew id, and the response carries what is stored, so the author sees it.\nThe edit belongs to the version of the task it is made on: once the task\nchanges, reviews no longer use it.","operationId":"put_criteria_override_api_v1_documents__document_id__criteria_override_put","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaOverrideRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaViewResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["criteria"],"summary":"Delete Criteria Override","description":"Reset the author's edit; the model's list, if any, is in force again.\n\n**200** with the body the other two routes return, as the key's routes do.\nThe reset is soft: the edit stays as a snapshot. Resetting a task that has\nno edit is not an error.","operationId":"delete_criteria_override_api_v1_documents__document_id__criteria_override_delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaViewResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/nodes/{node_id}/tests":{"post":{"tags":["tests"],"summary":"Create Written Test","description":"Create a test written in the system in a node (task 07b).\n\nThe body is the test of PRE-FLIGHT section 6.1: ``application/json`` for\nthe structure, ``application/yaml`` or ``text/yaml`` for its YAML, at most\n256 KiB, and it names the test: without a ``title`` it is refused, 422\n``TEST_FIELD_INVALID`` at the place the field is missing. The draft may be\nunfinished — no questions yet, a question short of options or of a right\none, an empty text (task 07c). **201** with the test — its document, its\ntitle, its draft with the letters a publication would give it,\n``published: null`` and ``incomplete``, what is left to finish. Nothing is\npublished and nothing is asked for; no student sees the test before its\nfirst publication.\n\n**404** ``Node not found`` for a node that is not there or not this\ntenant's — one body for both. **422** or **413** with\n``{\"code\", \"details\", \"place\"}`` for a draft the format refuses; **400**\n``SECURITY_REJECTED`` for a text a Stage 1 screen refuses; **415** for any\nother body.","operationId":"create_written_test_api_v1_nodes__node_id__tests_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Node Id"}}],"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WrittenTestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tests/{document_id}/draft":{"get":{"tags":["tests"],"summary":"Read Written Test","description":"The draft of a test written in the system, and its version in force.\n\nThe draft carries the marks the author set and the letters a publication\nwould give its options now, in the course's alphabet; ``published`` is the\nversion in force — its number, its ``version`` as a student is shown it,\nwhen it was published — or ``null`` before the first publication. From\ntask 07c: ``course_root_id``, the course the test is in;\n``unpublished_changes``, whether a publication now would give a new\nversion; ``incomplete``, every place the draft is not finished at; and\n``check``, what the check of the draft as it stands found — its state and,\nonce ready, the model's explanations and doubts by question. Reading asks\nfor nothing.\n\n**404** ``Document not found`` for a test that is not there or not this\ntenant's — one body for both; **422** ``NOT_A_TEST_OBJECT`` for another\ndocument of this tenant.","operationId":"read_written_test_api_v1_tests__document_id__draft_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WrittenTestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["tests"],"summary":"Replace Written Test Draft","description":"Replace the draft whole, from JSON or YAML; a title given renames the test.\n\nA body the format refuses leaves the draft as it was; an unfinished one is\nsaved as it is (task 07c), and ``incomplete`` says what is left. A title\nis the document's, not the draft's, so it changes at once and no\npublication carries it; a body without one keeps the title. Nothing is\npublished and nothing is asked for.\n\nThe refusals of creating a test, and **404** / **422** as for reading one.","operationId":"replace_written_test_draft_api_v1_tests__document_id__draft_put","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WrittenTestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tests/{document_id}/check":{"post":{"tags":["tests"],"summary":"Check Written Test","description":"Have the model explain the draft and name its doubts (task 07c).\n\nAsks for the explanations of the saved draft in the course language, as a\npublication of it would, and publishes nothing: no student and no channel\nsees anything of it. **200** with the check: ``in_progress`` while its\nexplanations are being written — asked for now or earlier, so pressing\nagain costs nothing — or ``ready``, with the model's explanations and\ndoubts, for a draft checked before and unchanged since. A check that\nfailed, or was left with no job to finish it, is asked for again. The\ndraft's reading shows the same.\n\n**422** ``TEST_DRAFT_INCOMPLETE`` for a draft not finished yet, with every\nunfinished place; nothing is asked for. **409** ``GENERATION_IN_PROGRESS``\nwhen another job of the test is in flight: nothing of the check is kept,\nand the author checks again once it ends. **404** / **422** as for reading\na test.","operationId":"check_written_test_api_v1_tests__document_id__check_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WrittenTestCheck"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tests/{document_id}/publish":{"post":{"tags":["tests"],"summary":"Publish Written Test","description":"Publish the draft (PRE-FLIGHT section 8).\n\n**201** with a new version; **200** when the draft equals the version in\nforce — the same version. The explanations in the course language are\nasked for when the version's axes are new, and again only when the ones\nasked for before failed or were left stuck (section 8.6). A draft checked\nbefore has its axes asked for already: its publication pays nothing more\n(task 07c).\n\n**409** ``GENERATION_IN_PROGRESS`` when another job of the test is in\nflight: nothing of the publication is kept, and the author publishes again\nonce it ends (decision 12). **422** ``TEST_DRAFT_INCOMPLETE`` for a draft\nnot finished yet, with every unfinished place; nothing is written (task\n07c). **404** / **422** as for reading a test.","operationId":"publish_written_test_api_v1_tests__document_id__publish_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WrittenTestPublicationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tests/{document_id}/yaml":{"get":{"tags":["tests"],"summary":"Export Written Test","description":"The draft as canonical YAML — the author's own, and only the author's.\n\nThe key is in it — the marks and the author's explanations — so it is served\nto the author's scope alone; no route of a student or a channel returns it.\nReading it back gives the same title and draft (section 6.4).\n\n**404** / **422** as for reading a test.","operationId":"export_written_test_api_v1_tests__document_id__yaml_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/yaml":{}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/homework/submit":{"post":{"tags":["homework"],"summary":"Submit Homework","description":"Submit homework for review.\n\nAccepts a file upload with metadata, stores it in S3,\ncreates a Student (if new) and HomeworkSubmission record,\nand enqueues processing into the homework queue.\n\nReturns 202 Accepted with submission and job IDs for tracking.","operationId":"submit_homework_api_v1_homework_submit_post","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_submit_homework_api_v1_homework_submit_post"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HomeworkSubmitResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"APIKeyHeader":[]}]}},"/api/v1/homework/tasks/{authored_document_id}/test":{"get":{"tags":["homework"],"summary":"Get Test Structure","description":"A test as the channel shows it to its student — questions, never the key.\n\nTask 07: the questions and options as the author wrote them, the version to\nsend back with the answers, and whether answers are taken now. Nothing of\nthe key is returned.\n\n**404** ``Task not found.`` for a task that is not there, is deleted, or is\nanother tenant's — one body for all three — and in the same body for a task\nthat is not a published test written in the system (task 07b).","operationId":"get_test_structure_api_v1_homework_tasks__authored_document_id__test_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Authored Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestStructureResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/homework/submit-test":{"post":{"tags":["homework"],"summary":"Submit Test Answers","description":"Answer a test with its answers, from a channel (task 07).\n\nThe anchor gates of ``POST /homework/submit`` — the course and the node in\nthis tenant, the task in this course — and the test routes' gate: a\npublished test written in the system, or a missing task (task 07b). Then\nthe test's own doors, each with its code (``{\"code\", \"details\"}``):\nanswers while tests are still answered with a file, to another version of\nthe test, or naming what the test does not have. A refused submission\nstores nothing. The review is delivered by webhook, as for a file; the\nsame answers sent twice are two attempts.","operationId":"submit_test_answers_api_v1_homework_submit_test_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/HomeworkTestSubmitRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HomeworkSubmitResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"APIKeyHeader":[]}]}},"/api/v1/tenant/webhook":{"patch":{"tags":["homework"],"summary":"Set or clear the tenant default webhook URL","description":"Update the default webhook URL for the authenticated tenant.\n\nThis URL is used as fallback when a homework submission does not\nspecify its own ``webhook_url``.  Send empty string or omit the\nfield to clear the default.","operationId":"update_tenant_webhook_api_v1_tenant_webhook_patch","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_update_tenant_webhook_api_v1_tenant_webhook_patch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TenantWebhookResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"APIKeyHeader":[]}]}},"/api/v1/homework/tasks/{authored_document_id}/base":{"get":{"tags":["homework"],"summary":"Get Task Base","description":"Base descriptor for a project task (KD18 P2, mode-1 API-key).\n\nReturns the active base — the latest READY version — with its echo-match\n``snapshot_hash`` and a presigned GET of the original archive. If a base\nexists but none is READY yet, returns the latest version with its state\n(``pending`` / ``failed``, ``snapshot_hash`` null). 404 if the task carries\nno base at all (not an empty object).","operationId":"get_task_base_api_v1_homework_tasks__authored_document_id__base_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Authored Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectBaseDescriptorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/jobs":{"get":{"tags":["jobs"],"summary":"List Jobs","description":"List the author's work — live plus recent (step A door 1).\n\nAuthor scope only (``require_scope(PREP)``) — deliberately tighter than the\nsingle-job ``GET /jobs/{id}``, which stays shared with the check contour;\nthese doors never return homework/cleanup work. Filters mirror the storage\nmethod (§4); an out-of-universe ``job_types`` value is a 422.","operationId":"list_jobs_api_v1_jobs_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"state_class","in":"query","required":false,"schema":{"anyOf":[{"enum":["in_flight","at_rest"],"type":"string"},{"type":"null"}],"title":"State Class"}},{"name":"completed_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed After"}},{"name":"material_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Material Id"}},{"name":"job_types","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Job Types"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/jobs/history":{"get":{"tags":["jobs"],"summary":"List Job History","description":"History of the author's materials — one row per material, latest work as\ncurrent state plus a work count (step A door 2). Grouped server-side.","operationId":"list_job_history_api_v1_jobs_history_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterialHistoryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/jobs/{job_id}":{"get":{"tags":["jobs"],"summary":"Get Job","description":"Get job status by ID.\n\nTenant isolation enforced via job.course_node_id → node.tenant_id,\nwith fallback to job.tenant_id. Returns 404 if the job does\nnot exist or does not belong to the current tenant.","operationId":"get_job_api_v1_jobs__job_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/jobs/{job_id}/reactivate":{"post":{"tags":["jobs"],"summary":"Reactivate Job","description":"Reactivate a failed Job for retry (vision §3 KD13).\n\nDB transition: ``status: 'failed' → 'queued'``, clears\n``error_message`` and run-attempt timestamps, preserves\n``stage_progress`` (worker resumes from KD4a checkpoint) and\n``queued_at`` (first-queued metadata). Then enqueues a new ARQ\ntask via the legacy-aware dispatcher and stores the new\n``arq_job_id`` on the Job. All in one outer transaction; failure\nrolls back, leaving the Job in its original ``failed`` state.\n\n**Per-entity state caveat.** Reactivate transitions only the\n``Job`` row — entity-level state (e.g. ``AuthoredDocument`` ingestion\nstate, ``HomeworkSubmission`` review state) is NOT touched. Workers\nmust defensively reset entity-level state on task entry (e.g.\n``AuthoredDocumentRepository.set_pending`` is called by\n``arq_ingest_material`` itself for exactly this reason). If a\nworker lacks this guard, entity state may diverge from Job state;\nuse the per-entity retry endpoint (e.g. ``materials.py /retry``)\nwhen that semantics matters more than the ``stage_progress``\nresume that ``reactivate`` provides.\n\nReturns 200 with the updated Job representation. Errors:\n\n* **404** — Job not found or not visible to current tenant.\n* **409** — Job is in a state other than ``'failed'`` (or is\n  soft-deleted).\n* **422** — ``job_type`` not supported by the dispatcher, or\n  ``input_params`` missing required fields for re-enqueue.","operationId":"reactivate_job_api_v1_jobs__job_id__reactivate_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"job_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Job Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/cost/summary":{"get":{"tags":["cost"],"summary":"Get Cost Summary","description":"Tenant-wide cost summary for the requested date range.\n\n``from``/``to`` are optional — see module docstring for fallback\nsemantics. The ``from > to`` guard runs on resolved values so the\nordering invariant survives both omitted-params and explicit-params\ncallers identically.\n\nThe by_course breakdown groups by direct ``Job.course_node_id``\n(Variant D per KD5 §5 row 0.4 deliberation). Jobs targeting\nnon-root nodes appear as their own rows; UI clients aggregate at\ncourse-root level. Phase 1+ task may evaluate walk-up CTE or\nschema denormalization (``CourseNode.root_course_id``) based on\nproduction query latency.\n\nSecurity: ``?no_cache=true`` always hits the DB. Absolute frequency\nis bounded by the ``require_scope(PREP, CHECK)`` rate limit per\nplan (so a misbehaving caller cannot exceed their plan's QPS), but\nevery bypassed cache hit multiplies DB load relative to the same\nrate of cached requests. Each bypass emits a WARNING log\n(``cost_no_cache_bypass``) for audit. A separate, tighter\nrate-limit bucket for ``no_cache=true`` traffic is captured as a\nPhase 1+ follow-up — natural fit alongside the admin-scope work\nthat KD5 defers.","operationId":"get_cost_summary_api_v1_cost_summary_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"From"}},{"name":"to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"To"}},{"name":"limit_courses","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Courses"}},{"name":"offset_courses","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Courses"}},{"name":"limit_providers","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Providers"}},{"name":"offset_providers","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Providers"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CostSummaryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/cost/homework":{"get":{"tags":["cost"],"summary":"Get Homework Cost","description":"Homework-processing cost for the tenant — total + by-course (level 1).\n\n``from``/``to`` are optional with the same fallback semantics as\n``/cost/summary`` (see module docstring). Drill into a course via each\n``by_course`` row's ``course_node_id``.","operationId":"get_homework_cost_api_v1_cost_homework_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"From"}},{"name":"to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"To"}},{"name":"limit_courses","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Courses"}},{"name":"offset_courses","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Courses"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HomeworkCostResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/cost/homework/course/{course_node_id}":{"get":{"tags":["cost"],"summary":"Get Homework Cost Course","description":"Homework cost for one course, grouped by task (level 2).\n\nDrilled by the parent level's ``course_node_id``. Soft-deleted tasks appear\nwith ``is_deleted=true`` (shown, not filtered). A foreign or unknown course\nreturns an empty ``by_task`` (leak-safe — see the section note above).","operationId":"get_homework_cost_course_api_v1_cost_homework_course__course_node_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"course_node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Course Node Id"}},{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"From"}},{"name":"to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"To"}},{"name":"limit_tasks","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Tasks"}},{"name":"offset_tasks","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Tasks"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HomeworkCourseCostResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/cost/homework/task/{authored_document_id}":{"get":{"tags":["cost"],"summary":"Get Homework Cost Task","description":"Homework cost for one task, grouped by student — the leaf (level 3).\n\nEach student's ``cost_usd`` sums across ALL their submissions for the task.\nA foreign or unknown task returns an empty ``by_student`` (leak-safe).","operationId":"get_homework_cost_task_api_v1_cost_homework_task__authored_document_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Authored Document Id"}},{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"From"}},{"name":"to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"To"}},{"name":"limit_students","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Students"}},{"name":"offset_students","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Students"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HomeworkTaskCostResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/cost/course/{course_node_id}":{"get":{"tags":["cost"],"summary":"Get Cost Course","description":"Cost drill-down for a single course (subtree drill-down).\n\n``from``/``to`` are optional with the same fallback semantics as\n``/cost/summary`` — see module docstring.\n\nSecurity: ``?no_cache=true`` always hits the DB and additionally\nre-runs the recursive descendant CTE in ``get_descendant_ids``.\nSame rate-limit boundedness + audit-log story as ``/cost/summary``;\nsee that endpoint's docstring.","operationId":"get_cost_course_api_v1_cost_course__course_node_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"course_node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Course Node Id"}},{"name":"from","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"From"}},{"name":"to","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"To"}},{"name":"limit_nodes","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Nodes"}},{"name":"offset_nodes","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Nodes"}},{"name":"limit_actions","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Actions"}},{"name":"offset_actions","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Actions"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CourseCostResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/feedback/submissions/{submission_id}":{"post":{"tags":["feedback"],"summary":"Touch Submission Review","description":"Answer whether this review helped — from the caller's own channel.\n\nThe student is resolved by ``student_external_id`` WITHIN the key's tenant\nand is never created: the pair (tenant, external id) is unique, so the\nlookup either names exactly one student of this tenant or names nobody.\n\nEverything that does not resolve — an unknown external id, a submission of\nanother student, an unknown submission id, a soft-deleted one, or a key of\nanother tenant, which cannot resolve the student in the first place — ends\nas the SAME generic 404 the portal gives, with the same body. The scope is\nCHECK, the scope the caller's submissions already travel on.","operationId":"touch_submission_review_api_v1_feedback_submissions__submission_id__post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"submission_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"The submission whose review is being answered.","title":"Submission Id"},"description":"The submission whose review is being answered."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChannelTouchRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackTouch"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/feedback/counters/course/{course_node_id}":{"get":{"tags":["feedback"],"summary":"Get Course Touch Counters","description":"How students answered about this course's reviews, task by task.\n\nThe author's side of the touch, and the only surface it has in this task —\na read route, no screen. Scope PREP, like the rest of the author's routes:\nCHECK is the channel students' submissions travel on, and it touches rather\nthan reads statistics. Adding CHECK later is one line; taking back a\nvisibility that was already granted is not.\n\nA course of another tenant, or one that does not exist, answers with an\nempty list — never a 404 that would tell an author which ids exist\nelsewhere. Soft-deleted tasks keep their rows; soft-deleted submissions and\nstudents do not count, which the repository holds in one place.","operationId":"get_course_touch_counters_api_v1_feedback_counters_course__course_node_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"course_node_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"Root CourseNode (the course) to break down by task.","title":"Course Node Id"},"description":"Root CourseNode (the course) to break down by task."},{"name":"limit_tasks","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":0,"default":50,"title":"Limit Tasks"}},{"name":"offset_tasks","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset Tasks"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CourseTouchCountersResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/feedback/counters/task/{authored_document_id}":{"get":{"tags":["feedback"],"summary":"Get Task Touch Counters","description":"The totals for one task, over every student who answered.\n\nZeros for a foreign or unknown task, for the same reason the course level\nanswers with an empty list.","operationId":"get_task_touch_counters_api_v1_feedback_counters_task__authored_document_id__get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"The task whose reviews were answered about.","title":"Authored Document Id"},"description":"The task whose reviews were answered about."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskTouchCountersResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/storage/files":{"get":{"tags":["storage"],"summary":"List Files","description":"List all files in tenant's S3 storage.","operationId":"list_files_api_v1_storage_files_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/StorageFileResponse"},"type":"array","title":"Response List Files Api V1 Storage Files Get"}}}}},"security":[{"APIKeyHeader":[]}]}},"/api/v1/storage/usage":{"get":{"tags":["storage"],"summary":"Get Usage","description":"Get total storage usage for the tenant.","operationId":"get_usage_api_v1_storage_usage_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StorageUsageResponse"}}}}},"security":[{"APIKeyHeader":[]}]}},"/api/v1/storage/files/{key}":{"delete":{"tags":["storage"],"summary":"Delete File","description":"KD3 soft-delete the matching AuthoredDocument + ARQ-deferred S3 cleanup.\n\nTwo-mode dispatch (vision §3 KD3 + §6 QQ5; mirrors commit (k)\n``delete_node`` / commit (l) ``delete_document`` pattern with cascade\nrooted at AuthoredDocument plus a force-orphan fallback for keys with\nno DB row):\n\n- **Mode A — with DB row.** ``get_by_source_url`` resolves to an\n  ``AuthoredDocument``; cascade soft-delete dispatches the four-\n  phase hook chain (cancel → invalidate → scrub → write deleted_at\n  → flush per cascade engine invariant). ``on_cancel_jobs`` flips\n  the document's own in-flight ingestion Job to\n  ``status='cancelled'`` per vision §KD13 (L1b: JCS keys on\n  ``Job.subject_id IN victim_ids`` and the document id is in the\n  victim set — direct bind, no augmentation; mirrors\n  ``delete_document``). ``on_invalidate_hashes`` cascade\n  hook bridges :class:`ContentHashService.invalidate_subtree` so\n  parent-chain hash recompute treats victims as already gone\n  (Gap 3 from commit (i)). Class-level ``__scrub_callable__``\n  clears ``filename`` + ``source_url`` (KD3 marker per hotfix-4).\n  DocumentSummary + DocumentSegment cascade chain is no-op in\n  Phase 1 per Amendment 16.\n- **Mode B — force-orphan.** No ``AuthoredDocument`` references the\n  URL (e.g. an upload that crashed before the row landed, or a\n  manually placed file). No cascade work; ``enqueue_s3_cleanup``\n  with the single key. Helper still creates a ``Job`` row carrying\n  the tenant scope so cost-attribution + reactivate-flow remain\n  eligible per KD13.\n\nBoth modes route the actual S3 ``DeleteObject`` call through the\nARQ ``s3_cleanup_task`` worker (probe §1.7 idempotent on missing).\nBehavior shift vs the legacy synchronous ``s3.delete_object``\nin-handler: deletion becomes eventually-consistent post-202-style\nhandoff. Intended per PHASE.md §2.2 sub-area ``kd3`` third bullet —\nsame QQ5 ordering invariant the other two KD3 handlers obey.\n\nTenant isolation gate (preserved from legacy): caller's tenant\nprefix MUST match the key prefix; cross-tenant requests yield\nHTTP 403 BEFORE any DB lookup or cascade work.","operationId":"delete_file_api_v1_storage_files__key__delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string","title":"Key"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/config/languages":{"get":{"tags":["config"],"summary":"Get Allowed Languages","description":"Return the project-wide course-language whitelist.\n\nCodes are canonical ISO 639-3; ``name_en`` comes from ``iso639`` and\n``name_native`` from ``config/language_names.yaml`` (CLDR). Both are\npopulated for every code on the whitelist.","operationId":"get_allowed_languages_api_v1_config_languages_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllowedLanguagesResponse"}}}}},"security":[{"APIKeyHeader":[]}]}},"/api/v1/portal/login":{"post":{"tags":["portal"],"summary":"Portal Login","description":"Authenticate a student and issue a bearer session token.","operationId":"portal_login_api_v1_portal_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalLoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalLoginResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/me":{"get":{"tags":["portal"],"summary":"Portal Me","description":"Return the student's identity, recovery-email status and language.\n\n``preferred_language`` comes off the session context rather than a\nsecond read: authenticating already loaded the ``Student`` row. It is\nhere rather than on its own endpoint because the portal fetches this\nonce at boot and the submission form needs the value to open on the\nstudent's standing choice.","operationId":"portal_me_api_v1_portal_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalMeResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/portal/languages":{"get":{"tags":["portal"],"summary":"Get Portal Languages","description":"Return the language whitelist for the portal's review-language field.\n\nSame list, same shape and same source (``config/languages.yaml``) as\nthe author route: the student picks the language their review is\nwritten in, and the server accepts only what is on this list (422\notherwise). Serving it means the form can be a closed selector, so the\n422 is never reached by an ordinary choice.\n\nAuthenticated but not otherwise gated: the list is the same for every\nstudent of every tenant, and requiring a session only keeps the\nendpoint off the public surface.","operationId":"get_portal_languages_api_v1_portal_languages_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllowedLanguagesResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/portal/submission-policy":{"get":{"tags":["portal"],"summary":"Get Portal Submission Policy","description":"Return what the submission door accepts, per assignment kind.\n\nThe form used to hold its own copy of both the extension list and the\nsize cap, with nothing failing when the server moved and it did not:\nbefore PR #49 the copy was 27 formats short, so a student could not\neven pick a ``.docx`` the server would have accepted, and the 10 MiB\nnumber it still carries cuts off a project archive the server allows at\n100 MB. Serving the numbers is what lets both copies go (``DD-SP-V``).\n\nEvery field is READ from the door it describes — no constant is defined\nhere, and none of the three sources is reshaped:\n\n* ``accept`` — :data:`ALLOWED_HOMEWORK_EXTENSIONS`, which is already\n  dot-prefixed (``\".py\"``) because that is the form\n  ``validate_homework_file`` compares against. Sorted for a stable body.\n* ``max_bytes`` — :data:`MAX_HOMEWORK_SIZE` or\n  :data:`PROJECT_SUBMISSION_MAX_UPLOAD_BYTES`, chosen by the same\n  ``task_type == PROJECT`` test the submission route makes.\n* ``archive_only`` — true exactly where ``project_preflight`` refuses a\n  loose file with ``ARCHIVE_ONLY``.\n\nAuthenticated but not otherwise gated, like the language list beside\nit: the policy is identical for every student of every tenant, and the\nsession only keeps the endpoint off the public surface.","operationId":"get_portal_submission_policy_api_v1_portal_submission_policy_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubmissionPolicyResponse"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/portal/tasks/{authored_document_id}/submissions":{"post":{"tags":["portal"],"summary":"Submit Portal Homework","description":"Submit a solution from the student's session (mode-2 in_app).\n\nThe student supplies only the task (``authored_document_id`` in the path) +\nthe file; the node-context (root course + specific node) is DERIVED from the\ntask anchor (KD15). Gates, in order: file validation → the task is a real,\nready, non-methodological task in the student's tenant → the student is\nenrolled in its course (Q1) → readiness. Every access failure collapses to a\ngeneric 404 (a methodological document is invisible in the tree, step A). The\nsubmission funnels through the shared core with ``delivery_mode='in_app'``,\nso no webhook fires and it terminates at ``completed`` for the read-path.","operationId":"submit_portal_homework_api_v1_portal_tasks__authored_document_id__submissions_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"AuthoredDocument (task) the submission answers.","title":"Authored Document Id"},"description":"AuthoredDocument (task) the submission answers."}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_submit_portal_homework_api_v1_portal_tasks__authored_document_id__submissions_post"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalSubmitResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["portal"],"summary":"List Portal Submissions","description":"List the student's own attempts on a task, newest first (read-path).\n\nVisibility (Q1): the task is visible if the student OWNS at least one attempt\non it (own history survives un-enrollment) OR is currently enrolled in its\ncourse. Otherwise a generic 404 — never leaking which tasks exist outside the\nstudent's access. An enrolled student with no attempts gets an empty list.\nSoft-deleted attempts are excluded (Q7). A methodological task is hidden\n(step A) via the enrolled-visibility gate — a document methodological from\nthe start accepts no submissions, so it has no attempts and collapses to the\ngeneric 404. Exception, by design: attempts submitted BEFORE the document was\nswitched to methodological stay listable — the own-attempts early-return\nstands ahead of the role gate, same nature as the untouched submission-detail\nroute keyed on ``submission_id`` (own history is not retracted).","operationId":"list_portal_submissions_api_v1_portal_tasks__authored_document_id__submissions_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"AuthoredDocument (task) to list the student's attempts for.","title":"Authored Document Id"},"description":"AuthoredDocument (task) to list the student's attempts for."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PortalSubmissionListItem"},"title":"Response List Portal Submissions Api V1 Portal Tasks  Authored Document Id  Submissions Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/tasks/{authored_document_id}/test":{"get":{"tags":["portal"],"summary":"Get Portal Test","description":"A test as the student answers it — questions, never the key (task 07).\n\nThe same access gates as a file submission: the task visible to students,\nin the student's tenant, in a course they are enrolled in — one 404 for\nevery way of failing them. The same 404 for a task that is not a published\ntest written in the system (task 07b): the form is its version in force.","operationId":"get_portal_test_api_v1_portal_tasks__authored_document_id__test_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Authored Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestStructureResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/tasks/{authored_document_id}/test-submissions":{"post":{"tags":["portal"],"summary":"Submit Portal Test","description":"Answer a test with its answers, from the student's session (task 07).\n\nThe access gates of a file submission and the test routes' gate — a\npublished test written in the system, or a missing task — then the test's\nown doors, each with its code (``{\"code\", \"details\"}``). A refused submission\nstores nothing; the review is read in the portal, as for a file; the same\nanswers sent twice are two attempts.","operationId":"submit_portal_test_api_v1_portal_tasks__authored_document_id__test_submissions_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Authored Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalTestSubmitRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalSubmitResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/tasks/{authored_document_id}/base":{"get":{"tags":["portal"],"summary":"Get Portal Task Base","description":"Presigned GET of a project task's active base ORIGINAL (KD18 P5, KD17).\n\nThe student downloads the AUTHOR's original base archive (never the\nnormalized snapshot, KD17) to build their submission. This is the portal\n(bearer-session) counterpart of the mode-1 API-key base route: the same\nactive-base = latest-READY resolution and original-archive presign, re-scoped\nto the student's enrollment. Access failures — unknown task, a\nnon-project/non-task document, a methodological document (invisible in the\ntree, step A), a foreign tenant, or not enrolled — collapse to one generic\n404 (rule #12). A visible task with no READY base yet returns\na DISTINCT 404 (the descriptor already exposes the base state, so it leaks\nnothing new).","operationId":"get_portal_task_base_api_v1_portal_tasks__authored_document_id__base_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"The project task whose active base to download.","title":"Authored Document Id"},"description":"The project task whose active base to download."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalBaseDownload"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/submissions/{submission_id}":{"get":{"tags":["portal"],"summary":"Get Portal Submission","description":"Read one of the student's own submissions — the full curated slice.\n\nOwnership-only authorization (Q1): a non-owned, unknown, or soft-deleted\nsubmission collapses to the same generic 404. The internal trace is never\nserialized: not safety_result, not sanity_result, and not the layered\nreview_result of a pre-rebuild review. A version-1 review stores a review\nstructure in that column instead, and that is not a trace — it is the\nreview, written for the student, and it goes out whole in ``structure``\n(task 04).","operationId":"get_portal_submission_api_v1_portal_submissions__submission_id__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"submission_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"The submission to read (must be the student's own).","title":"Submission Id"},"description":"The submission to read (must be the student's own)."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalSubmissionDetail"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/submissions/{submission_id}/feedback":{"post":{"tags":["portal"],"summary":"Touch Portal Submission","description":"Answer whether this review helped — the student's own session (task 05).\n\nOne of two doors into the same core; this one authenticates with the portal\nsession and names the student from it. The submission is resolved by the\nSAME helper the detail route uses, so a foreign, unknown or soft-deleted one\nreaches the core as ``None`` and comes back as the one generic 404 the read\npath already gives.\n\nThe core decides everything after that — it re-checks owner and liveness,\nrefuses with 409 when there is no review to answer about, and writes the row\nreplacing this student's previous answer if there was one. The route does\nnot repeat any of it: a check that lives in two places is a check that will\none day differ between them.","operationId":"touch_portal_submission_api_v1_portal_submissions__submission_id__feedback_post","security":[{"HTTPBearer":[]}],"parameters":[{"name":"submission_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"The submission whose review is being answered.","title":"Submission Id"},"description":"The submission whose review is being answered."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalTouchRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeedbackTouch"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/materials/{authored_document_id}":{"get":{"tags":["portal"],"summary":"Get Portal Material","description":"Issue how to render a material to the student (originals-only, KD17).\n\nGated by enrollment (Q7); any access failure → generic 404. The descriptor\ndiscriminates on ``source_type``:\n\n- ``presentation`` → ``slides`` with fresh presigned-GET URLs for the\n  persisted WebP renders (empty list for a pre-T3 presentation, never 500).\n- uploaded media (an S3 ``source_url``) → ``file`` with a fresh\n  presigned-GET URL.\n- YouTube/web (a non-S3 ``source_url``) → ``external`` with the link.\n\npresigned-GET URLs are issued fresh on every call (KD17); session lifetime\nbeyond the TTL → page reload re-issues (seamless refresh is DD-6-B).","operationId":"get_portal_material_api_v1_portal_materials__authored_document_id__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"authored_document_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"The material (AuthoredDocument) to render.","title":"Authored Document Id"},"description":"The material (AuthoredDocument) to render."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalMediaResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/courses":{"get":{"tags":["portal"],"summary":"List Portal Courses","description":"List the student's enrolled courses — bare (id + title only).\n\nThe landing screen is deliberately cheap: no counts, no peek into the tree\n(that is the per-course materials endpoint). Enrollment IS the visibility\ngate (publish-gate A): the list is exactly the student's active enrollments;\nsoft-deleted course roots are filtered out. An un-enrolled student gets an\nempty list — never an error (there is nothing whose existence to leak).","operationId":"list_portal_courses_api_v1_portal_courses_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/PortalCourseListItem"},"type":"array","title":"Response List Portal Courses Api V1 Portal Courses Get"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/portal/courses/{root_id}/materials":{"get":{"tags":["portal"],"summary":"Get Portal Course Materials","description":"Return the course material tree with a per-task submission overlay.\n\nEnrollment-gated (publish-gate A); any access failure — unknown root, a\nnon-root id, a foreign tenant, soft-deleted, or not enrolled — collapses to\none generic 404 (rule #12). The tree is curated: soft-deleted nodes /\ndocuments, non-READY documents, methodological documents (role allowlist)\nand every test but a published one written in the system (task 07b) are\nfiltered out, each document carries only its task-vs-material ``kind`` +\n(for tasks) a submission overlay, and the internal trace never leaks. The\noverlay is one query per course (no N+1), grouped by the task anchor in\nPython.","operationId":"get_portal_course_materials_api_v1_portal_courses__root_id__materials_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"root_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","description":"The course (root CourseNode) to render the tree for.","title":"Root Id"},"description":"The course (root CourseNode) to render the tree for."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PortalMaterialTreeNode"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/recovery-email":{"post":{"tags":["portal"],"summary":"Set Recovery Email","description":"Set / change the student's recovery email and mail a confirm link.\n\nChanging the address resets its confirmed state and burns any outstanding\nconfirm token, so only the newest link works. The student is authenticated,\nso a missing credential is not expected — treated as 404 defensively.","operationId":"set_recovery_email_api_v1_portal_recovery_email_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecoveryEmailRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecoveryEmailResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"HTTPBearer":[]}]}},"/api/v1/portal/recovery-email/confirm":{"post":{"tags":["portal"],"summary":"Confirm Recovery Email","description":"Confirm the recovery email from the mailed token (public).","operationId":"confirm_recovery_email_api_v1_portal_recovery_email_confirm_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConfirmRecoveryEmailRequest"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/password/forgot":{"post":{"tags":["portal"],"summary":"Forgot Password","description":"Request a password-reset link (public, anti-enumeration).\n\nAlways returns a generic 202. A link is sent only when the credential is\nactive AND has a CONFIRMED recovery email; an unknown login, a pending or\nabsent recovery address, or a revoked credential all take the same silent\n202 path — an unverified address is never a channel for a reset token.","operationId":"forgot_password_api_v1_portal_password_forgot_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForgotPasswordRequest"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/portal/password/reset":{"post":{"tags":["portal"],"summary":"Reset Password","description":"Set a new password from the mailed reset token (public).\n\nThe new password is validated + hashed BEFORE the token is redeemed, so a\ntoo-short password (422) does not burn the token. Redemption is single-use;\nthe credential is then live-checked for ``is_active`` — a reset rotates only\nthe secret and never revives a revoked credential.","operationId":"reset_password_api_v1_portal_password_reset_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResetPasswordRequest"}}},"required":true},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/students":{"post":{"tags":["students"],"summary":"Provision Student","description":"Provision a student (3 external_id modes) and a portal credential.\n\nStudent and credential are created in one transaction — a credential\nconflict rolls back the student too, so no student is left without a\ncredential.","operationId":"provision_student_api_v1_students_post","security":[{"APIKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProvisionStudentRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProvisionStudentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["students"],"summary":"List Students","description":"List the tenant's students with portal-access state + enrollment count.\n\nIncludes integration-mode students without a portal credential\n(``login`` / ``is_active`` null) so the admin can attach one via the\n``existing`` provisioning mode. Soft-deleted students are excluded;\nnewest-first, paginated.","operationId":"list_students_api_v1_students_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Max students per page (1-100).","default":20,"title":"Limit"},"description":"Max students per page (1-100)."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Students to skip for pagination.","default":0,"title":"Offset"},"description":"Students to skip for pagination."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudentRosterResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/students/{student_id}/revoke":{"post":{"tags":["students"],"summary":"Revoke Access","description":"Revoke a student's portal access (is_active=False). Student + history kept.","operationId":"revoke_access_api_v1_students__student_id__revoke_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"student_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Student Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/students/{student_id}/restore":{"post":{"tags":["students"],"summary":"Restore Access","description":"Restore a previously revoked student's portal access (is_active=True).","operationId":"restore_access_api_v1_students__student_id__restore_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"student_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Student Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/students/{student_id}/password":{"post":{"tags":["students"],"summary":"Set Student Password","description":"Reset a student's portal password (DD-6-J — author-side fallback).\n\nThe last-resort path when a student has no confirmed recovery email (the\nself-service reset is primary). Mirrors revoke/restore's by-id, tenant-\nscoped form: rehashes the existing credential's ``password_hash`` (argon2id,\nsame min-length policy as provisioning) and never touches ``is_active`` —\nresetting the secret is the student's tier, not access. 404 if the student\nis unknown to the tenant or has no credential.","operationId":"set_student_password_api_v1_students__student_id__password_post","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"student_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Student Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetStudentPasswordRequest"}}}},"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/students/enrollments":{"post":{"tags":["students"],"summary":"Bind Enrollment","description":"Bind a student to a course (root CourseNode). Validates root-ness.","operationId":"bind_enrollment_api_v1_students_enrollments_post","security":[{"APIKeyHeader":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EnrollmentRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Bind Enrollment Api V1 Students Enrollments Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["students"],"summary":"Unbind Enrollment","description":"Unbind a student↔course grant (row DELETE). 404 if not bound.","operationId":"unbind_enrollment_api_v1_students_enrollments_delete","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"student_id","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"Student to unenroll.","title":"Student Id"},"description":"Student to unenroll."},{"name":"course_node_id","in":"query","required":true,"schema":{"type":"string","format":"uuid","description":"Course (root node).","title":"Course Node Id"},"description":"Course (root node)."}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/students/{student_id}/enrollments":{"get":{"tags":["students"],"summary":"List Student Enrollments","description":"List a student's course enrollments (oldest-first).\n\nEnrollments to a since-soft-deleted course are preserved (unlike the\nportal listing) so the admin can see and unbind them; the FE resolves\neach course title from the roots it already holds. 404 for a student\noutside the tenant or soft-deleted (existence not leaked).","operationId":"list_student_enrollments_api_v1_students__student_id__enrollments_get","security":[{"APIKeyHeader":[]}],"parameters":[{"name":"student_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Student Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StudentEnrollmentsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AllowedLanguagesResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/LanguageEntry"},"type":"array","title":"Items","description":"Allowed languages — canonical 639-3 + display names."},"total":{"type":"integer","title":"Total","description":"Convenience count of ``items`` (always equals ``len(items)``)."}},"type":"object","required":["items","total"],"title":"AllowedLanguagesResponse","description":"Response shape for ``GET /api/v1/config/languages``.\n\nReturns the project-wide course-language whitelist (canonical ISO 639-3\ncodes enriched with English names from ``iso639`` and native names from\n``config/language_names.yaml``). UI consumes this as the single source of\ntruth for the language selector — no hardcoded list on the client."},"AssignmentType":{"type":"string","enum":["test","short_task","task","project"],"title":"AssignmentType","description":"Assignment types with increasing complexity and duration.\n\nTranslocated from models/methodist.py in C9.0 (K-stop-1 resolution):\nAuthoredDocument.task_type is a core document taxonomy attribute,\nnot a methodist-internal concept. Methodist legacy layer is deleted\nin C9.3 (Phase 5 + methodist cleanup)."},"AuthoredDocumentCreateResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Unique entry identifier (UUIDv7)."},"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"Parent node this material belongs to."},"source_type":{"type":"string","title":"Source Type","description":"Material type: ``video``, ``presentation``, ``text``, or ``web``."},"material_role":{"type":"string","title":"Material Role","description":"Role: ``educational`` or ``methodological``."},"task_type":{"anyOf":[{"$ref":"#/components/schemas/AssignmentType"},{"type":"null"}],"description":"Assignment type if this material is a concrete task, else ``null``."},"source_url":{"type":"string","title":"Source Url","description":"URL or S3 path to the raw material."},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename","description":"Original filename, if available."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"The name both trees show (task 07b); ``null`` for a document without one."},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language","description":"Canonical ISO 639-3 language. ``null`` when unset and not yet auto-detected."},"order":{"type":"integer","title":"Order","description":"0-based position among sibling materials."},"state":{"type":"string","title":"State","description":"Derived lifecycle state (will be ``pending``)."},"processing_phase":{"type":"string","title":"Processing Phase","description":"External processing phase (L3, root cause #2): on create/confirm/retry this is ``queued`` (the ingestion job was just enqueued and no worker has taken it). Full vocabulary elsewhere: ``queued`` | ``processing`` | ``awaiting_author`` | ``ready`` | ``error``. Never null/empty.","default":"queued"},"job_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Job Id","description":"ID of the auto-enqueued ingestion job for progress tracking."},"warnings":{"items":{"type":"string"},"type":"array","title":"Warnings","description":"Non-fatal warnings (e.g. unverified platform)."},"processing_estimate":{"anyOf":[{"$ref":"#/components/schemas/ProcessingEstimate"},{"type":"null"}],"description":"Informational queue-wait hint (DD-3.3c-I-B); ``null`` until the route attaches it on successful intake."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this entry was created."}},"type":"object","required":["id","course_node_id","source_type","material_role","source_url","filename","order","state","created_at"],"title":"AuthoredDocumentCreateResponse","description":"Response for authored document creation.\n\nExtends the base response with ``job_id`` — the ID of the\ningestion job that was auto-enqueued."},"AuthoredDocumentResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Unique entry identifier (UUIDv7)."},"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"Parent node this material belongs to."},"course_root_id":{"type":"string","format":"uuid","title":"Course Root Id","description":"Root CourseNode of the course this document's node belongs to (KD-delta denormalised root). Projection of the already-stored ``AuthoredDocument.course_root_id`` — a NOT NULL column resolved by a parent walk at creation, so this is always present (never null); no migration, no read-time resolution. Equals ``course_node_id`` when the document sits on a root node."},"source_type":{"type":"string","title":"Source Type","description":"Material type: ``video``, ``presentation``, ``text``, or ``web``."},"material_role":{"type":"string","title":"Material Role","description":"Role: ``educational`` or ``methodological``."},"task_type":{"anyOf":[{"$ref":"#/components/schemas/AssignmentType"},{"type":"null"}],"description":"Assignment type if this material is a concrete task, else ``null``."},"source_url":{"type":"string","title":"Source Url","description":"URL or S3 path to the raw material."},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename","description":"Original filename, if available."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"The name both trees show (task 07b); ``null`` for a document without one."},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language","description":"Canonical ISO 639-3 language. ``null`` when unset and not yet auto-detected."},"order":{"type":"integer","title":"Order","description":"0-based position among sibling materials."},"state":{"type":"string","title":"State","description":"Derived lifecycle state: ``pending``, ``ready``, or ``error``."},"processing_phase":{"type":"string","title":"Processing Phase","description":"External processing phase (L3, root cause #2): ``queued`` (standing in the queue, no worker has taken it), ``processing`` (a worker is processing it), ``awaiting_author`` (№21: prep produced a file-role proposal the author has not yet confirmed), ``ready``, or ``error``. Splits the in-flight ``pending`` state into ``queued`` vs ``processing``; the terminal values (``ready`` / ``error``) mirror ``state`` verbatim. Never null/empty."},"error_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message","description":"Error message from the last failed processing attempt, if any."},"error_category":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Category","description":"Structural async-error code (F4), if categorised."},"job_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Job Id","description":"Job ID currently processing this material, or ``null``."},"file_roles":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"File Roles","description":"№21 author file-role markup — the full ``{proposal, decision}`` JSONB (proposal written by the DOCUMENT_PREPARATION job; decision by the confirm endpoint). ``null`` until prep has run. The confirm screen reads this whole object."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this entry was created."},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"When this entry was last modified."}},"type":"object","required":["id","course_node_id","course_root_id","source_type","material_role","source_url","filename","order","state","processing_phase","error_message","job_id","created_at","updated_at"],"title":"AuthoredDocumentResponse","description":"Response schema for a single authored document."},"AuthoredDocumentSummaryResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Unique entry identifier (UUIDv7)."},"source_type":{"type":"string","title":"Source Type","description":"Material type: ``video``, ``presentation``, ``text``, or ``web``."},"material_role":{"type":"string","title":"Material Role","description":"Role: ``educational`` or ``methodological``."},"task_type":{"anyOf":[{"$ref":"#/components/schemas/AssignmentType"},{"type":"null"}],"description":"Assignment type if this material is a concrete task. ``null`` for regular (non-task) materials."},"source_url":{"type":"string","title":"Source Url","description":"URL or S3 path to the raw material."},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename","description":"Original filename, if available."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"The name both trees show (task 07b); ``null`` for a document without one."},"order":{"type":"integer","title":"Order","description":"0-based position among sibling materials."},"state":{"type":"string","title":"State","description":"Derived lifecycle state: ``pending``, ``ready``, or ``error``."},"processing_phase":{"type":"string","title":"Processing Phase","description":"External processing phase (L3, root cause #2): ``queued`` (standing in the queue, no worker has taken it), ``processing`` (a worker is processing it), ``awaiting_author`` (№21: prep produced a file-role proposal the author has not yet confirmed), ``ready``, or ``error``. Splits the in-flight ``pending`` state into ``queued`` vs ``processing``; the terminal values (``ready`` / ``error``) mirror ``state`` verbatim. Never null/empty."},"error_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message","description":"Error from the last failed processing attempt, if any."},"error_category":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Category","description":"Structural async-error code (F4), if categorised."},"test_state":{"anyOf":[{"type":"string","enum":["draft","published","changed"]},{"type":"null"}],"title":"Test State","description":"Publication state of a test written in the system (``source_type`` ``test_object``): ``draft`` (never published), ``published`` (the draft equals the latest version), ``changed`` (published, and the draft differs from the latest version — by the full digest, so a pass-mark edit alone counts; the rule of the draft's ``unpublished_changes``). ``null`` for any other document. Computed for the whole tree in one batch query."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this entry was created."}},"type":"object","required":["id","source_type","material_role","source_url","filename","order","state","processing_phase","error_message","created_at"],"title":"AuthoredDocumentSummaryResponse","description":"Compact authored document within the tree detail.\n\nA lighter version of ``AuthoredDocumentResponse`` omitting\n``job_id`` and ``updated_at`` to keep the tree\npayload concise. Includes the derived ``state`` field."},"AuthoredDocumentUpdateRequest":{"properties":{"material_role":{"anyOf":[{"$ref":"#/components/schemas/MaterialRole"},{"type":"null"}],"description":"New role: ``educational`` or ``methodological``."},"task_type":{"anyOf":[{"$ref":"#/components/schemas/AssignmentType"},{"type":"null"}],"description":"Assignment taxonomy tier. Pass ``null`` to clear. Omit the field to keep the current value."}},"type":"object","title":"AuthoredDocumentUpdateRequest","description":"Request body for PATCH /documents/{document_id}.\n\nAll fields are optional — only fields present in the request body\nare updated. Field presence is detected via ``model_fields_set``.\nPassing ``task_type: null`` explicitly clears the task flag."},"Body_attach_project_base_api_v1_documents__document_id__base_post":{"properties":{"file":{"type":"string","format":"binary","title":"File","description":"Base project archive (.zip / .tar.gz / .tgz / .gz)."}},"type":"object","required":["file"],"title":"Body_attach_project_base_api_v1_documents__document_id__base_post"},"Body_create_document_api_v1_nodes__node_id__documents_post":{"properties":{"source_type":{"$ref":"#/components/schemas/SourceType","description":"Document type: video, presentation, text, web, audio, or code."},"material_role":{"$ref":"#/components/schemas/MaterialRole","description":"Role: educational (delivers content) or methodological (declares course intent).","default":"educational"},"task_type":{"anyOf":[{"$ref":"#/components/schemas/AssignmentType"},{"type":"null"}],"description":"Mark the document as a concrete task of the given taxonomy tier (test, short_task, task, project). Omit for regular documents."},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url","description":"URL to the source document. Required if no file."},"file":{"anyOf":[{"type":"string","format":"binary"},{"type":"null"}],"title":"File","description":"File upload (multipart). Accepted formats: presentation (pdf, pptx), text (md, txt, docx, html), video (mp4, webm, mkv, avi), audio (mp3, wav, m4a, ogg, flac), code (source files or a .zip project archive). Required if source_url is not provided."},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename","description":"Override filename (optional, defaults to uploaded name)."},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language","description":"Optional language override. Accepts ISO 639-1 / 639-3 / English name; stored as canonical ISO 639-3. When empty, the course default is used and STT falls back to auto-detection."}},"type":"object","required":["source_type"],"title":"Body_create_document_api_v1_nodes__node_id__documents_post"},"Body_submit_homework_api_v1_homework_submit_post":{"properties":{"student_external_id":{"type":"string","title":"Student External Id","description":"Student identifier from the caller's system."},"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"Root CourseNode UUID representing the course."},"node_id":{"type":"string","format":"uuid","title":"Node Id","description":"Specific course node the submission targets."},"file":{"type":"string","format":"binary","title":"File","description":"Homework file (code, text, or archive)."},"authored_document_id":{"type":"string","format":"uuid","title":"Authored Document Id","description":"AuthoredDocument (task) the submission answers — the single submission↔task anchor (KD15). Must be a task (task_type set), belong to this course, and not be deleted."},"webhook_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Url","description":"Per-submission webhook URL override (falls back to tenant default)."},"response_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Response Language","description":"Language of the review; ISO 639-1 or 639-3 accepted, stored as 639-3. If omitted: the student's stored preference, then the course language."},"student_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Student Note","description":"D7-local: the student's comment or question for this submission (free text). Steers the Mentor review of this one attempt; distinct from the student's standing mentor_preferences (D7-global), which is not a submission field. Up to 2000 characters, a line break counted as one and the edges trimmed; refused with STUDENT_NOTE_TOO_LONG or STUDENT_NOTE_REJECTED (422) before anything is stored."},"base_snapshot_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Base Snapshot Hash","description":"KD18 P3: for a project task with a ready base, the snapshot_hash of the base version this submission was built from (echoed from GET /homework/tasks/{id}/base). Required for a project-with-base submission; ignored for other task types."}},"type":"object","required":["student_external_id","course_node_id","node_id","file","authored_document_id"],"title":"Body_submit_homework_api_v1_homework_submit_post"},"Body_submit_portal_homework_api_v1_portal_tasks__authored_document_id__submissions_post":{"properties":{"file":{"type":"string","format":"binary","title":"File","description":"Homework file (code, text, or archive)."},"response_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Response Language","description":"Language of the review; ISO 639-1 or 639-3 accepted, stored as 639-3. If omitted: the student's stored preference, then the course language."},"student_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Student Note","description":"D7-local: the student's comment or question for this submission (free text). Up to 2000 characters, a line break counted as one and the edges trimmed; refused with STUDENT_NOTE_TOO_LONG or STUDENT_NOTE_REJECTED (422) before anything is stored."},"base_snapshot_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Base Snapshot Hash","description":"KD18 P3: for a project task with a ready base, the snapshot_hash of the base version this submission was built from (the portal sends it automatically from the base descriptor). Required for a project-with-base submission; ignored otherwise."}},"type":"object","required":["file"],"title":"Body_submit_portal_homework_api_v1_portal_tasks__authored_document_id__submissions_post"},"Body_update_tenant_webhook_api_v1_tenant_webhook_patch":{"properties":{"webhook_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Url","description":"Default webhook URL, or empty/null to clear."}},"type":"object","title":"Body_update_tenant_webhook_api_v1_tenant_webhook_patch"},"ByActionEntry":{"properties":{"action":{"type":"string","title":"Action"},"cost_usd":{"type":"number","title":"Cost Usd"},"by_kind":{"$ref":"#/components/schemas/CostByKind"}},"type":"object","required":["action","cost_usd","by_kind"],"title":"ByActionEntry","description":"One action label in the by-action breakdown of /cost/course/{id}."},"ByCourseEntry":{"properties":{"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id"},"course_title":{"type":"string","title":"Course Title"},"cost_usd":{"type":"number","title":"Cost Usd"},"by_kind":{"$ref":"#/components/schemas/CostByKind"}},"type":"object","required":["course_node_id","course_title","cost_usd","by_kind"],"title":"ByCourseEntry","description":"One course root in the by-course breakdown of /cost/summary."},"ByNodeEntry":{"properties":{"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id"},"title":{"type":"string","title":"Title"},"cost_usd":{"type":"number","title":"Cost Usd"},"by_kind":{"$ref":"#/components/schemas/CostByKind"}},"type":"object","required":["course_node_id","title","cost_usd","by_kind"],"title":"ByNodeEntry","description":"One descendant node in the by-node breakdown of /cost/course/{id}."},"ByProviderEntry":{"properties":{"provider":{"type":"string","title":"Provider"},"model_id":{"type":"string","title":"Model Id"},"cost_usd":{"type":"number","title":"Cost Usd"},"by_kind":{"$ref":"#/components/schemas/CostByKind"}},"type":"object","required":["provider","model_id","cost_usd","by_kind"],"title":"ByProviderEntry","description":"One (provider, model) pair in the by-provider breakdown."},"ChannelTouchRequest":{"properties":{"kind":{"$ref":"#/components/schemas/FeedbackKind","description":"The kind of feedback. Today: touch."},"value":{"$ref":"#/components/schemas/FeedbackValue","description":"The answer: helped or not_helped."},"student_external_id":{"type":"string","minLength":1,"title":"Student External Id","description":"Student identifier from the caller's system, as used on submission. Resolved within the key's tenant; never created here."}},"additionalProperties":false,"type":"object","required":["kind","value","student_external_id"],"title":"ChannelTouchRequest","description":"The same answer, arriving from the caller's channel (task 05).\n\nAdds the one thing a tenant key cannot know by itself: WHICH student is\nanswering, named by the identifier the caller's own system uses. The touch\nnever creates that student — unlike a submission, which does."},"CheckMethod":{"type":"string","enum":["model_verdict","mandatory_points","code_test"],"title":"CheckMethod","description":"How a criterion is checked (``TASK.md`` 3.1).\n\n* ``MODEL_VERDICT`` — a reviewing model judges the submission against the\n  criterion's text and evidence.\n* ``MANDATORY_POINTS`` — the criterion holds when each of its mandatory\n  points is present; the points travel with the criterion.\n* ``CODE_TEST`` — a check by running code. Nothing runs code tests in the\n  first echelon, so every such criterion carries ``soft_descent`` and is\n  judged by a model verdict meanwhile (``03-BINDING.md`` 2.4)."},"ChildNodeCreateRequest":{"properties":{"title":{"type":"string","maxLength":500,"minLength":1,"title":"Title","description":"Node title displayed in the material tree.","examples":["Module 1: Introduction"]},"description":{"anyOf":[{"type":"string","maxLength":5000},{"type":"null"}],"title":"Description","description":"Optional detailed description of the node's purpose.","examples":["Overview of the foundational concepts covered in this module."]},"default_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Language","description":"Optional language override for this subtree. Stored as canonical ISO 639-3 when set. Pipeline currently reads only the root language; child overrides are forward-compat metadata.","examples":["uk","ukr"]}},"type":"object","required":["title"],"title":"ChildNodeCreateRequest","description":"Request body for creating a non-root (child) node.\n\n``default_language`` is optional on children — language inheritance\nis resolved at ingestion time against the root, and any value set on\na child is currently unused by the pipeline (KD-2.4-13 §1). Kept\nhere for forward-compat in case cascade overrides land later.\n\nExample::\n\n    {\"title\": \"Module 1: Intro\"}"},"ConfirmRecoveryEmailRequest":{"properties":{"token":{"type":"string","minLength":1,"title":"Token","description":"The email-confirm token."}},"type":"object","required":["token"],"title":"ConfirmRecoveryEmailRequest","description":"Request body for POST /portal/recovery-email/confirm (public)."},"ConfirmUploadRequest":{"properties":{"key":{"type":"string","maxLength":2000,"minLength":1,"title":"Key","description":"S3 object key returned by upload-url endpoint."},"source_type":{"$ref":"#/components/schemas/SourceType","description":"Material type (video, presentation, text, web, audio, code)."},"material_role":{"$ref":"#/components/schemas/MaterialRole","description":"Role: ``educational`` (delivers content) or ``methodological`` (declares course intent).","default":"educational"},"task_type":{"anyOf":[{"$ref":"#/components/schemas/AssignmentType"},{"type":"null"}],"description":"Mark the material as a concrete task of the given taxonomy tier. ``null`` for regular materials."},"filename":{"anyOf":[{"type":"string","maxLength":500},{"type":"null"}],"title":"Filename","description":"Override filename (defaults to key basename)."},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language","description":"Optional language override. Accepts ISO 639-1 / 639-3 / English name; stored as canonical ISO 639-3. When absent, the course default is used, and STT auto-detection is the final fallback.","examples":["uk","ukr"]}},"type":"object","required":["key","source_type"],"title":"ConfirmUploadRequest","description":"Request body for POST /nodes/{node_id}/documents/confirm-upload."},"CostByKind":{"properties":{"exact_usd":{"type":"number","title":"Exact Usd"},"estimated_usd":{"type":"number","title":"Estimated Usd"},"unknown_calls":{"type":"integer","title":"Unknown Calls"}},"type":"object","required":["exact_usd","estimated_usd","unknown_calls"],"title":"CostByKind","description":"A cost figure split by the kind of its register sums (BIL-C, decision 7).\n\n``exact_usd`` — sums from every usage detail the provider reported at the\nprices in force at the call. ``estimated_usd`` — sums computed under a\nnamed approximation (a failed call's input counted by us, cached tokens\nwithout a cached price, ...). ``unknown_calls`` — paid calls with no\ndefensible sum: counted, never summed as zero. The figure's ``cost_usd`` /\n``total_usd`` beside it is ``exact_usd + estimated_usd``."},"CostSummaryResponse":{"properties":{"from":{"type":"string","format":"date","title":"From"},"to":{"type":"string","format":"date","title":"To"},"total_usd":{"type":"number","title":"Total Usd"},"total_by_kind":{"$ref":"#/components/schemas/CostByKind"},"unattributed_cost_usd":{"type":"number","title":"Unattributed Cost Usd"},"unattributed_by_kind":{"$ref":"#/components/schemas/CostByKind"},"by_course":{"items":{"$ref":"#/components/schemas/ByCourseEntry"},"type":"array","title":"By Course"},"by_provider":{"items":{"$ref":"#/components/schemas/ByProviderEntry"},"type":"array","title":"By Provider"}},"type":"object","required":["from","to","total_usd","total_by_kind","unattributed_cost_usd","unattributed_by_kind","by_course","by_provider"],"title":"CostSummaryResponse","description":"Response shape for ``GET /api/v1/cost/summary``.\n\nInvariant: ``total_usd == sum(by_course.cost_usd) + unattributed_cost_usd``,\nand the same for each field of the ``*_by_kind`` splits.\n``unattributed_cost_usd`` aggregates ESCs whose ``Job.course_node_id``\nis NULL (orphan jobs without a course attribution)."},"CourseCostResponse":{"properties":{"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id"},"course_title":{"type":"string","title":"Course Title"},"from":{"type":"string","format":"date","title":"From"},"to":{"type":"string","format":"date","title":"To"},"total_usd":{"type":"number","title":"Total Usd"},"total_by_kind":{"$ref":"#/components/schemas/CostByKind"},"by_node":{"items":{"$ref":"#/components/schemas/ByNodeEntry"},"type":"array","title":"By Node"},"by_action":{"items":{"$ref":"#/components/schemas/ByActionEntry"},"type":"array","title":"By Action"}},"type":"object","required":["course_node_id","course_title","from","to","total_usd","total_by_kind","by_node","by_action"],"title":"CourseCostResponse","description":"Response shape for ``GET /api/v1/cost/course/{course_node_id}``.\n\nDrill-down: ``by_node`` covers the entire subtree rooted at\n``course_node_id`` (recursive via ``parent_id``)."},"CourseTouchCountersResponse":{"properties":{"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id"},"by_task":{"items":{"$ref":"#/components/schemas/TaskTouchCountersEntry"},"type":"array","title":"By Task"}},"type":"object","required":["course_node_id","by_task"],"title":"CourseTouchCountersResponse","description":"Response of ``GET /api/v1/feedback/counters/course/{course_node_id}``.\n\nA course whose id belongs to another tenant — or to nobody — answers with\nan empty list rather than an error, the way the cost breakdown does: an\nauthor must not learn which courses exist elsewhere by comparing codes."},"CriteriaInForceResponse":{"properties":{"layer":{"$ref":"#/components/schemas/CriteriaLayer","description":"``author`` — the author's edit; ``model`` — the model's list."},"criteria":{"items":{"$ref":"#/components/schemas/Criterion"},"type":"array","title":"Criteria"}},"type":"object","required":["layer","criteria"],"title":"CriteriaInForceResponse","description":"The list a review of the task's current version uses, and whose it is."},"CriteriaLayer":{"type":"string","enum":["author","model"],"title":"CriteriaLayer","description":"Which layer a list in force comes from (task 08).\n\nHalf of a verdict's address: identifiers are stable within one list, not\nacross the lists of one task version (task 08, ``TASK.md`` section 9,\ndecision 21), so a verdict names its criterion by the list's layer and row\ntogether with the identifier."},"CriteriaOverrideRequest":{"properties":{"criteria":{"items":{"$ref":"#/components/schemas/CriterionEdit"},"type":"array","maxItems":60,"minItems":1,"title":"Criteria","description":"Every criterion of the edit, in order. Each has ``id`` (omitted for a new criterion), ``text`` and ``evidence`` (up to 600 characters each), ``weight`` (``must``, ``should`` or ``may``), ``check_method`` (``model_verdict``, ``mandatory_points``, or ``code_test`` for a project), ``concepts`` (up to 10, from the reading's ``concepts``) and ``mandatory_points`` (up to 10, each an ``id`` — omitted for a new point — and a ``text`` of up to 300 characters; there exactly when ``check_method`` is ``mandatory_points``). ``soft_descent`` is the code's and is not sent."}},"additionalProperties":false,"type":"object","required":["criteria"],"title":"CriteriaOverrideRequest","description":"Body of PUT /documents/{id}/criteria/override (mentor-rebuild task 08).\n\nThe author's criteria list, whole: a criterion left out is out of the edit,\nand there is no partial form — a review uses the list as one. A criterion\nor a mandatory point sent with its ``id`` keeps it; one sent without is new\nand gets its id from the code."},"CriteriaReasonCode":{"type":"string","enum":["task_not_processed","models_unavailable","limit_reached","composition_error","not_attempted"],"title":"CriteriaReasonCode","description":"Why no list is composed for the task's version — the key of the advice.\n\nA code, not a sentence: the surface picks its own words by it\n(language-rules); the reading carries the Ukrainian sentence of\n:data:`NOT_COMPOSED_MESSAGES` beside it, for the author who reads the\nresponse as it is. Every advice leads somewhere in this system — none\nsuggests editing the task's text, which makes a new version and leaves the\nworks already waiting where they are."},"CriteriaStatus":{"type":"string","enum":["awaiting_first_submission","composing","not_composed","ready"],"title":"CriteriaStatus","description":"Whether a list is in force for the task's current version, and if not, why."},"CriteriaViewResponse":{"properties":{"status":{"$ref":"#/components/schemas/CriteriaStatus","description":"``ready`` — a list is in force; ``awaiting_first_submission`` — none yet, and no student's work waits for one: it is composed after the first submission; ``composing`` — one is being composed now; ``not_composed`` — none, though works wait for one or the last attempt failed: ``reason_code`` says why."},"reason_code":{"anyOf":[{"$ref":"#/components/schemas/CriteriaReasonCode"},{"type":"null"}],"description":"Why no list is composed — the key a surface picks its advice by; null unless ``status`` is ``not_composed``."},"waiting_submissions":{"type":"integer","minimum":0.0,"title":"Waiting Submissions","description":"How many students' works of this task wait for a criteria list before they can be reviewed.","default":0},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message","description":"While no list is in force, the state and what the author can do — in Ukrainian; null otherwise."},"model":{"anyOf":[{"items":{"$ref":"#/components/schemas/Criterion"},"type":"array"},{"type":"null"}],"title":"Model","description":"The model's list for the current version of the task; null while none is composed."},"author":{"anyOf":[{"items":{"$ref":"#/components/schemas/Criterion"},"type":"array"},{"type":"null"}],"title":"Author","description":"The author's edit for the current version; null when there is none. An edit of an earlier version is not shown: it is not carried to a new one."},"in_force":{"anyOf":[{"$ref":"#/components/schemas/CriteriaInForceResponse"},{"type":"null"}],"description":"The list a review uses: the author's edit when there is one, else the model's list; null while neither is."},"contradictions":{"items":{"type":"string"},"type":"array","title":"Contradictions","description":"Where the task contradicts the description of its node, as found when the model's list was composed — for the author only, never a criterion or a score."},"concepts":{"items":{"type":"string"},"type":"array","title":"Concepts","description":"The concepts a criterion of this task may name: the main concepts of its node and course, then any the list in force or the model's list names beyond them."}},"type":"object","required":["status"],"title":"CriteriaViewResponse","description":"What the author sees about a task's criteria (mentor-rebuild task 08).\n\nReturned by all three routes, as the key's routes do, so the author reads\none shape — after an edit, after a reset, and when merely looking."},"Criterion":{"properties":{"id":{"type":"string","pattern":"^c[1-9][0-9]*$","title":"Id"},"text":{"type":"string","maxLength":600,"minLength":1,"title":"Text"},"evidence":{"type":"string","maxLength":600,"minLength":1,"title":"Evidence"},"weight":{"$ref":"#/components/schemas/WeightCategory"},"check_method":{"$ref":"#/components/schemas/CheckMethod"},"soft_descent":{"type":"boolean","title":"Soft Descent"},"concepts":{"items":{"type":"string","minLength":1},"type":"array","maxItems":10,"title":"Concepts"},"mandatory_points":{"items":{"$ref":"#/components/schemas/MandatoryPoint"},"type":"array","maxItems":10,"title":"Mandatory Points"}},"additionalProperties":false,"type":"object","required":["id","text","evidence","weight","check_method","soft_descent","concepts","mandatory_points"],"title":"Criterion","description":"One criterion of a task version, as storage keeps it and readers take it.\n\n``id`` is the code's, unique in its list and fixed for the list's life; a\nmandatory point's ``id`` extends it (``c3`` → ``c3.p1``), so a point can be\nnamed on its own and still says whose it is. The weight number is\ncomputed, never stored.\n\n>>> criterion = Criterion(\n...     id=\"c1\",\n...     text=\"Handles an empty list\",\n...     evidence=\"returns [] for []\",\n...     weight=\"should\",\n...     check_method=\"model_verdict\",\n...     soft_descent=False,\n...     concepts=(),\n...     mandatory_points=(),\n... )\n>>> criterion.weight_number\n2"},"CriterionEdit":{"properties":{"id":{"anyOf":[{"type":"string","pattern":"^c[1-9][0-9]*$"},{"type":"null"}],"title":"Id"},"text":{"type":"string","maxLength":600,"minLength":1,"title":"Text"},"evidence":{"type":"string","maxLength":600,"minLength":1,"title":"Evidence"},"weight":{"$ref":"#/components/schemas/WeightCategory"},"check_method":{"$ref":"#/components/schemas/CheckMethod"},"concepts":{"items":{"type":"string","minLength":1},"type":"array","maxItems":10,"title":"Concepts","default":[]},"mandatory_points":{"items":{"$ref":"#/components/schemas/MandatoryPointEdit"},"type":"array","maxItems":10,"title":"Mandatory Points","default":[]}},"additionalProperties":false,"type":"object","required":["text","evidence","weight","check_method"],"title":"CriterionEdit","description":"A criterion as the author sends it: the stored form, identifiers optional.\n\nThe author replaces the whole list and may change every field of a\ncriterion (``TASK.md`` section 9, decision 17), except two that stay the\ncode's, as in :class:`CriterionDraft`: the identifiers of what is new and\n``soft_descent``, which follows ``check_method`` — so an edit that sends\nthe mark is refused as an extra field. ``id`` names a criterion of the list\nbeing edited, which the edit keeps; a criterion without one is new. The\nlimits are the stored form's. Whether an identifier, a check method or a\nconcept fits the task is decided against the task, by the author's edit\nservice, not here."},"DocumentStructureEntry":{"properties":{"path":{"type":"string","title":"Path","description":"Path inside the archive."},"size":{"type":"integer","title":"Size","description":"Size in bytes, as the archive declared it."},"reason":{"type":"string","title":"Reason","description":"Bare ``CodeStructureReason`` token (``vendored_dir``, ``lockfile``, ``charset_violation``, …)."},"detail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Detail","description":"What the token cannot say by itself — the matched directory, pattern or filename. Null when the path already carries it."},"entries":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Entries","description":"Raw files this row stands for (a collapsed denylist directory counts them all). Null when the row is one file."}},"type":"object","required":["path","size","reason"],"title":"DocumentStructureEntry","description":"One file of a code material that did not reach the model whole.\n\n``reason`` is the bare ``CodeStructureReason`` token — the key the\ninterface phrases from. ``detail`` is the half the token cannot carry\n(which directory, which pattern, which lockfile), and it is usually the\npart the author actually recognises: ``vendored_dir`` says little,\n``vendored_dir`` + ``node_modules`` says everything.\n\n``entries`` is how many raw files the row stands for. A denylist\ndirectory is collapsed to one row, so ``__MACOSX/`` with five files in\nit would otherwise read as a single skipped file. ``None`` where the\nconcept does not apply — a description-only row is always one file."},"DocumentStructureResponse":{"properties":{"excluded":{"items":{"$ref":"#/components/schemas/DocumentStructureEntry"},"type":"array","title":"Excluded","description":"Files whose contents never reached the model."},"description_only":{"items":{"$ref":"#/components/schemas/DocumentStructureEntry"},"type":"array","title":"Description Only","description":"Files named to the model as structure, contents not sent."}},"type":"object","title":"DocumentStructureResponse","description":"What a code material's processing left out, and why (step Г2 §1.3).\n\nTwo lists, because they are two different events and the author acts on\nthem differently: ``excluded`` was not read at all, ``description_only``\nwas named to the model as structure but its contents were not sent.\n\nFiles that WERE read are deliberately absent: this surface answers \"what\ndid I lose\", and the per-file role markup the author confirmed is already\nserved by ``file_roles`` on the document itself. Roles are absent for the\nsame reason — an excluded row has none (nothing can be restored from it).\n\nBoth lists empty is a real answer: the whole project was read."},"DraftCheckState":{"type":"string","enum":["not_checked","in_progress","ready","failed"],"title":"DraftCheckState","description":"How far the check of the draft as it stands got (task 07c).\n\n* ``NOT_CHECKED`` — nothing was asked for this draft: never checked, or\n  changed since. An unfinished draft is never checked.\n* ``IN_PROGRESS`` — its explanations are being written.\n* ``READY`` — its explanations and doubts are written.\n* ``FAILED`` — the writing gave up; a check asks for it again."},"EnrollmentRequest":{"properties":{"student_id":{"type":"string","format":"uuid","title":"Student Id","description":"Student to enroll."},"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"Root CourseNode (the course) to grant access to."}},"type":"object","required":["student_id","course_node_id"],"title":"EnrollmentRequest","description":"Request body for POST /students/enrollments (bind student↔course)."},"EntryClass":{"type":"string","enum":["text","document","binary"],"title":"EntryClass","description":"Content class assigned to every INCLUDED manifest entry."},"ExcludedEntry":{"properties":{"path":{"type":"string","title":"Path"},"reason":{"$ref":"#/components/schemas/ExcludedReason"},"entries":{"type":"integer","title":"Entries"},"size":{"type":"integer","title":"Size"}},"type":"object","required":["path","reason","entries","size"],"title":"ExcludedEntry"},"ExcludedReason":{"type":"string","enum":["denylist_dir","magic_mismatch","nested_archive","may_contain_secrets"],"title":"ExcludedReason","description":"Exhaustive reason set for an entry omitted from the snapshot.\n\n``FORBIDDEN_TYPE`` from the security layer is deliberately NOT here:\nan unknown extension is kept as an :attr:`EntryClass.BINARY`\nINCLUDED entry (hash-tracked), not excluded. Only these four\nreasons drop content from the snapshot."},"FeedbackKind":{"type":"string","enum":["touch"],"title":"FeedbackKind","description":"The kind of feedback a record carries.\n\n* ``TOUCH`` — one tap, no text. The cheapest thing a student can say, and\n  the only kind that fits between reading a review and closing the tab."},"FeedbackTouch":{"properties":{"kind":{"$ref":"#/components/schemas/FeedbackKind"},"value":{"$ref":"#/components/schemas/FeedbackValue"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["kind","value","updated_at"],"title":"FeedbackTouch","description":"A student's own answer about a review, as it is served back.\n\nNot ``Portal*``: this is the response of BOTH doors — the portal session\nand the caller's channel — and the shape the submission detail serves on\nthe next read. A name that promised one of the two would mislead whoever\nintegrates with the other.\n\n``updated_at`` is the time of the CURRENT answer: a repeat touch replaces\nthe previous one, and the time moves with it."},"FeedbackValue":{"type":"string","enum":["helped","not_helped"],"title":"FeedbackValue","description":"The answer itself.\n\n* ``HELPED`` / ``NOT_HELPED`` — the two answers a touch on a review can\n  carry.\n\nA closed list of strings rather than a boolean on purpose: a boolean has\nnowhere to put a third answer, and \"helped = false\" reads in SQL as an\nabsence rather than as an answer given."},"FileRolesDecisionRequest":{"properties":{"files":{"additionalProperties":{"type":"string","enum":["full","auxiliary","structure_only"]},"type":"object","title":"Files","description":"Per-file role decision. Keys MUST exactly cover the current ``proposal.files`` — a missing or extra path is a 422. Values: ``full`` | ``auxiliary`` | ``structure_only``."},"tree_digest":{"type":"string","title":"Tree Digest","description":"The ``proposal.tree_digest`` being confirmed against. Must equal the current proposal's digest, else the file tree changed and the confirmation is stale (409 ``file_roles_stale``)."}},"type":"object","required":["files","tree_digest"],"title":"FileRolesDecisionRequest","description":"№21 confirm-screen payload — the author's per-file role decision.\n\n``files`` must assign a role to EVERY proposal file and no others (the UI\nexpands folder gestures to per-file states; decision 3 / decision 4). Role\ntokens are the three of decision 2 — ``auxiliary`` is legal (author-chosen)."},"ForgotPasswordRequest":{"properties":{"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id","description":"Tenant the student belongs to."},"login":{"type":"string","maxLength":200,"minLength":1,"title":"Login","description":"Student login."}},"type":"object","required":["tenant_id","login"],"title":"ForgotPasswordRequest","description":"Request body for POST /portal/password/forgot (public).\n\nIdentifies the credential by ``(tenant_id, login)`` — the stable portal\nidentifier — not by email (recovery emails are optional and not unique).\nThe route always returns a generic 202 regardless of existence\n(anti-enumeration); a reset link is only sent to a confirmed recovery email."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HomeworkByCourseEntry":{"properties":{"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id"},"course_title":{"type":"string","title":"Course Title"},"cost_usd":{"type":"number","title":"Cost Usd"},"by_kind":{"$ref":"#/components/schemas/CostByKind"}},"type":"object","required":["course_node_id","course_title","cost_usd","by_kind"],"title":"HomeworkByCourseEntry","description":"One course root in the by-course breakdown of /cost/homework."},"HomeworkByStudentEntry":{"properties":{"student_id":{"type":"string","format":"uuid","title":"Student Id"},"student_display":{"type":"string","title":"Student Display"},"cost_usd":{"type":"number","title":"Cost Usd"},"by_kind":{"$ref":"#/components/schemas/CostByKind"}},"type":"object","required":["student_id","student_display","cost_usd","by_kind"],"title":"HomeworkByStudentEntry","description":"One student in the leaf breakdown of /cost/homework/task/{id}.\n\n``cost_usd`` sums the student's cost across ALL their submissions for the\ntask. ``student_display`` falls back display_name → login → external_id →\nid so mode-1 students without a portal display_name still render."},"HomeworkByTaskEntry":{"properties":{"authored_document_id":{"type":"string","format":"uuid","title":"Authored Document Id"},"task_label":{"type":"string","title":"Task Label"},"is_deleted":{"type":"boolean","title":"Is Deleted"},"cost_usd":{"type":"number","title":"Cost Usd"},"by_kind":{"$ref":"#/components/schemas/CostByKind"}},"type":"object","required":["authored_document_id","task_label","is_deleted","cost_usd","by_kind"],"title":"HomeworkByTaskEntry","description":"One task in the by-task breakdown of /cost/homework/course/{id}.\n\n``is_deleted`` marks a soft-deleted task (``AuthoredDocument.deleted_at``):\nshown, not filtered — its processing cost still counts. ``task_label`` is\nthe T4a-identical ``filename | {source_type} #{order}`` label."},"HomeworkCostResponse":{"properties":{"from":{"type":"string","format":"date","title":"From"},"to":{"type":"string","format":"date","title":"To"},"total_usd":{"type":"number","title":"Total Usd"},"total_by_kind":{"$ref":"#/components/schemas/CostByKind"},"by_course":{"items":{"$ref":"#/components/schemas/HomeworkByCourseEntry"},"type":"array","title":"By Course"}},"type":"object","required":["from","to","total_usd","total_by_kind","by_course"],"title":"HomeworkCostResponse","description":"Response shape for ``GET /api/v1/cost/homework`` (level 1).\n\nTenant-wide homework-processing cost: a period total plus the by-course\nbreakdown. ``by_course`` may be paginated, so ``total_usd`` stays whole\n(does not necessarily equal the sum of the returned page)."},"HomeworkCourseCostResponse":{"properties":{"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id"},"from":{"type":"string","format":"date","title":"From"},"to":{"type":"string","format":"date","title":"To"},"by_task":{"items":{"$ref":"#/components/schemas/HomeworkByTaskEntry"},"type":"array","title":"By Task"}},"type":"object","required":["course_node_id","from","to","by_task"],"title":"HomeworkCourseCostResponse","description":"Response shape for ``GET /api/v1/cost/homework/course/{course_node_id}``.\n\nLevel 2: the tasks of one course with their homework-processing cost. Each\ntask's ``cost_usd`` is the level-2 \"task total\" (so no separate\ntask-aggregate endpoint is needed). Drill into a task via its\n``authored_document_id``."},"HomeworkSubmitResponse":{"properties":{"submission_id":{"type":"string","format":"uuid","title":"Submission Id","description":"Unique ID of the created homework submission."},"student_id":{"type":"string","format":"uuid","title":"Student Id","description":"Student record ID (created or existing)."},"status":{"type":"string","title":"Status","description":"Initial status: ``received``."},"job_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Job Id","description":"Background job ID for tracking processing progress. For a cached duplicate, the job of the submission returned."},"duplicate":{"type":"boolean","title":"Duplicate","description":"True if this is a cached result from a prior identical submission.","default":false}},"type":"object","required":["submission_id","student_id","status"],"title":"HomeworkSubmitResponse","description":"Response for POST /homework/submit and POST /homework/submit-test (202)."},"HomeworkTaskCostResponse":{"properties":{"authored_document_id":{"type":"string","format":"uuid","title":"Authored Document Id"},"from":{"type":"string","format":"date","title":"From"},"to":{"type":"string","format":"date","title":"To"},"by_student":{"items":{"$ref":"#/components/schemas/HomeworkByStudentEntry"},"type":"array","title":"By Student"}},"type":"object","required":["authored_document_id","from","to","by_student"],"title":"HomeworkTaskCostResponse","description":"Response shape for ``GET /api/v1/cost/homework/task/{authored_document_id}``.\n\nLevel 3 (leaf): the students who submitted the task, each with their\nsummed homework-processing cost across all their attempts."},"HomeworkTestSubmitRequest":{"properties":{"student_external_id":{"type":"string","title":"Student External Id","description":"Student identifier from the caller's system."},"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"Root CourseNode UUID representing the course."},"node_id":{"type":"string","format":"uuid","title":"Node Id","description":"Specific course node the submission targets."},"authored_document_id":{"type":"string","format":"uuid","title":"Authored Document Id","description":"The test (an AuthoredDocument of task_type ``test``) the answers are given to; it must belong to this course."},"answers":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","title":"Answers","description":"The answers: question number → the labels of the options chosen, as the test prints them (``{\"1\": [\"B\"], \"2\": [\"A\", \"C\"]}``). Labels are compared in canonical form — case, spaces, a trailing ``)`` or ``.``, and Latin letters drawn like Cyrillic ones do not matter. A question left out is not refused: it counts as answered wrong."},"test_version":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Test Version","description":"The ``version`` of the test these answers were given to, as the structure route returned it. When it is not the test's current version the answers are refused with ``TEST_VERSION_CHANGED``; when omitted, the current version is assumed."},"webhook_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Url","description":"Per-submission webhook URL override (falls back to tenant default)."},"response_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Response Language","description":"Language of the review; ISO 639-1 or 639-3 accepted, stored as 639-3. If omitted: the student's stored preference, then the course language."},"student_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Student Note","description":"The student's comment on this attempt: up to 2000 characters, a line break counted as one and the edges trimmed. A longer comment is refused with ``STUDENT_NOTE_TOO_LONG``, one a text screen refuses (direction-changing, tag or control characters) with ``STUDENT_NOTE_REJECTED`` — both 422, before anything is stored."}},"additionalProperties":false,"type":"object","required":["student_external_id","course_node_id","node_id","authored_document_id","answers"],"title":"HomeworkTestSubmitRequest","description":"Body of POST /homework/submit-test (task 07): a test answered by a channel."},"IncompleteCode":{"type":"string","enum":["TEST_NO_QUESTIONS","TEST_TEXT_EMPTY","TEST_OPTIONS_COUNT","TEST_NO_CORRECT_OPTION"],"title":"IncompleteCode","description":"What is unfinished at a place — one code per rule."},"JobListItemResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"job_type":{"type":"string","title":"Job Type"},"job_state":{"type":"string","title":"Job State","description":"Work-state token (step A §3): ``queued`` | ``processing`` | ``ready`` | ``error`` | ``cancelled`` | ``obsolete``. Derived purely from Job.status — NOT the material phase (a job row never carries ``awaiting_author``)."},"queued_at":{"type":"string","format":"date-time","title":"Queued At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At","description":"Worker-pickup time; NULL for work terminated straight from the queue (never ran)."},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"subject_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Subject Type"},"subject_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Subject Id"},"material_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Material Id","description":"Resolved material anchor; NULL for node-summary work."},"display_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Display Name","description":"Material label as stored — a scrubbed marker for a deleted source; the server neither invents nor hides it."},"display_deleted":{"type":"boolean","title":"Display Deleted","description":"Whether the label's source material is soft-deleted."},"display_deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Display Deleted At","description":"Machine deletion moment from the source's ``deleted_at``; NULL when live."},"material_source_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Material Source Type","description":"Source type (document work only), for the deleted marker."},"base_version":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Base Version","description":"Base-archive version (base-normalize work only)."},"current_stage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Stage"},"stage_progress":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Stage Progress","description":"Per-job-type progress checkpoint as stored; shape not normalised here (a later step consumes it)."}},"type":"object","required":["id","job_type","job_state","queued_at","started_at","completed_at","subject_type","subject_id","material_id","display_name","display_deleted","display_deleted_at","material_source_type","base_version"],"title":"JobListItemResponse","description":"One row of the author work-list (door 1); also the ``last_job`` of a\nhistory row (door 2). Fields resolve server-side over one query (step A §4)."},"JobListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/JobListItemResponse"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"}},"type":"object","required":["items","total","limit","offset"],"title":"JobListResponse","description":"Door 1 — a flat page of author work."},"JobResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"job_type":{"type":"string","title":"Job Type"},"priority":{"type":"string","title":"Priority"},"status":{"type":"string","title":"Status"},"tenant_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Tenant Id"},"course_node_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Course Node Id"},"arq_job_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Arq Job Id"},"current_stage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Stage"},"stage_progress":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Stage Progress"},"result_data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Result Data"},"error_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message"},"error_category":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Category"},"queued_at":{"type":"string","format":"date-time","title":"Queued At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"}},"type":"object","required":["id","job_type","priority","status","tenant_id","course_node_id","arq_job_id","error_message","queued_at","started_at","completed_at"],"title":"JobResponse","description":"Response for GET /jobs/{job_id}."},"LanguageEntry":{"properties":{"code":{"type":"string","title":"Code","description":"Canonical ISO 639-3 three-letter code."},"name_en":{"type":"string","title":"Name En","description":"English name (from iso639 SIL table)."},"name_native":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name Native","description":"What the language calls itself, from ``config/language_names.yaml`` (CLDR). ``None`` only for a code missing from that file; the file covers the whole whitelist, and a test holds it to that."}},"type":"object","required":["code","name_en"],"title":"LanguageEntry","description":"One allowed language for the UI selector and validation."},"MandatoryPoint":{"properties":{"id":{"type":"string","pattern":"^c[1-9][0-9]*\\.p[1-9][0-9]*$","title":"Id"},"text":{"type":"string","maxLength":300,"minLength":1,"title":"Text"}},"additionalProperties":false,"type":"object","required":["id","text"],"title":"MandatoryPoint","description":"One mandatory point of a criterion — one thing a reviewer finds or not."},"MandatoryPointEdit":{"properties":{"id":{"anyOf":[{"type":"string","pattern":"^c[1-9][0-9]*\\.p[1-9][0-9]*$"},{"type":"null"}],"title":"Id"},"text":{"type":"string","maxLength":300,"minLength":1,"title":"Text"}},"additionalProperties":false,"type":"object","required":["text"],"title":"MandatoryPointEdit","description":"A mandatory point as the author sends it in an edit of a list.\n\n``id`` names a point of the list being edited, which the edit keeps; a\npoint without one is new, and the code gives it the next identifier of\nits criterion (``TASK.md`` section 9, decisions 17 and 21)."},"ManifestEntry":{"properties":{"path":{"type":"string","title":"Path"},"size":{"type":"integer","title":"Size"},"hash":{"type":"string","title":"Hash"},"cls":{"$ref":"#/components/schemas/EntryClass"}},"type":"object","required":["path","size","hash","cls"],"title":"ManifestEntry"},"MaterialHistoryItemResponse":{"properties":{"material_id":{"type":"string","format":"uuid","title":"Material Id"},"display_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Display Name"},"material_deleted":{"type":"boolean","title":"Material Deleted"},"material_deleted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Material Deleted At"},"material_source_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Material Source Type"},"processing_phase":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Processing Phase","description":"The material's real processing phase; NULL when the material is deleted (a deleted material is a marker, not a phase)."},"last_job":{"$ref":"#/components/schemas/JobListItemResponse"},"jobs_count":{"type":"integer","title":"Jobs Count","description":"Count of work over this material (from the same list — no separate computed number)."}},"type":"object","required":["material_id","display_name","material_deleted","material_deleted_at","material_source_type","processing_phase","last_job","jobs_count"],"title":"MaterialHistoryItemResponse","description":"Door 2 — one material with its work history (grouped server-side)."},"MaterialHistoryResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/MaterialHistoryItemResponse"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"}},"type":"object","required":["items","total","limit","offset"],"title":"MaterialHistoryResponse","description":"Door 2 — a page of materials with their history."},"MaterialRole":{"type":"string","enum":["educational","methodological"],"title":"MaterialRole","description":"Role of a material in the course. Mirrors ORM material_role_enum."},"NodeListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/NodeResponse"},"type":"array","title":"Items","description":"Root nodes for the current page."},"total":{"type":"integer","title":"Total","description":"Total number of root nodes (across all pages)."},"limit":{"type":"integer","title":"Limit","description":"Maximum items per page (as requested)."},"offset":{"type":"integer","title":"Offset","description":"Number of items skipped (as requested)."}},"type":"object","required":["items","total","limit","offset"],"title":"NodeListResponse","description":"Paginated list of root nodes (courses).\n\nRoot nodes (parent_id IS NULL) serve as top-level entities."},"NodeMoveRequest":{"properties":{"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Target parent node ID. Set to ``null`` to move the node to the tree root."}},"type":"object","required":["parent_id"],"title":"NodeMoveRequest","description":"Request body for moving a node within the tree.\n\nMove a node to a new parent (or to root by setting ``parent_id``\nto ``null``). Cycle detection is enforced server-side.\n\nExample::\n\n    {\"parent_id\": \"019c707f-73b8-7b53-ba02-0e7be1c89189\"}"},"NodeReorderRequest":{"properties":{"order":{"type":"integer","minimum":0.0,"title":"Order","description":"Desired 0-based position among siblings. Values exceeding the maximum are clamped automatically.","examples":[0,2]}},"type":"object","required":["order"],"title":"NodeReorderRequest","description":"Request body for reordering a node among its siblings.\n\nSiblings are automatically renumbered (0-based) after the operation.\n\nExample::\n\n    {\"order\": 2}"},"NodeResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Unique node identifier (UUIDv7)."},"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id","description":"Tenant this node belongs to."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Parent node ID, or ``null`` for root nodes."},"title":{"type":"string","title":"Title","description":"Node title."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Optional node description."},"default_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Language","description":"Canonical ISO 639-3 language for materials under this subtree. Root nodes (``parent_id`` is null) are guaranteed non-null by the ``course_nodes_root_language_required`` CHECK constraint; child nodes may be null (inheritance is resolved at ingestion from the root, not stored)."},"order":{"type":"integer","title":"Order","description":"0-based position among siblings."},"content_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Hash","description":"Merkle hash of this node's content. ``null`` if not computed."},"children_count":{"type":"integer","title":"Children Count","description":"Number of direct child nodes.","default":0},"authored_documents_count":{"type":"integer","title":"Authored Documents Count","description":"Number of authored documents attached to this node.","default":0},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this node was created."},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"When this node was last modified."}},"type":"object","required":["id","tenant_id","title","description","order","content_hash","created_at","updated_at"],"title":"NodeResponse","description":"Response schema for a single material tree node.\n\nReturned by create, update, move, and reorder operations.\nDoes not include nested children — use ``NodeTreeResponse``\nfor the full tree."},"NodeSummaryEditViewResponse":{"properties":{"final":{"$ref":"#/components/schemas/NodeSummaryFinalResponse"},"raw_observations":{"items":{"type":"string"},"type":"array","title":"Raw Observations","description":"Methodist observations from both passes (API-side alias for ``NodeSummaryRaw.methodist_observations``). Shown to the author before approve."},"previous_snapshot":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Previous Snapshot","description":"Snapshot of the prior Final state captured at the last automatic overwrite. ``null`` when no overwrite has occurred yet (the very first run produced the current Final via INSERT)."}},"type":"object","required":["final"],"title":"NodeSummaryEditViewResponse","description":"Response shape for ``GET /api/v1/node-summaries/{node_id}/edit-view``.\n\nCombined view per KD11 §1086-1101: editable Final + Raw's\n``methodist_observations`` (renamed to ``raw_observations`` at the\nAPI boundary per operator-ratified Phase 3.2.4 pre-flight question\n(в) — ORM column name unchanged) + the optional single-version\nsnapshot."},"NodeSummaryFinalResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Unique NodeSummaryFinal identifier (UUIDv7)."},"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"The CourseNode this Final summarises (1:1, UNIQUE)."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"Methodist-canonical title."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Methodist-canonical description."},"learning_objectives":{"items":{"type":"string"},"type":"array","title":"Learning Objectives","description":"Bloom-taxonomy outcome statements (3-7 typical)."},"knowledge":{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array","title":"Knowledge","description":"LearningOutcomeItem[]: {name, description} — what student will know."},"skills":{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array","title":"Skills","description":"LearningOutcomeItem[]: {name, description} — what student will do."},"success_criteria":{"items":{"type":"string"},"type":"array","title":"Success Criteria"},"assessment_approach":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Assessment Approach"},"teaching_approach":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Teaching Approach"},"key_activities":{"items":{"type":"string"},"type":"array","title":"Key Activities"},"common_mistakes":{"items":{"type":"string"},"type":"array","title":"Common Mistakes"},"main_concepts":{"items":{"type":"string"},"type":"array","title":"Main Concepts"},"secondary_concepts":{"items":{"type":"string"},"type":"array","title":"Secondary Concepts"},"enclosing_context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Enclosing Context","description":"Read-only on Final. Top-down refresh updates it WITHOUT resetting approved_at (KD11 §1066 third channel)."},"is_manual":{"type":"boolean","title":"Is Manual","description":"Forward-compat marker for the empty-leaf author-create flow. In Phase 3.2.4 no HTTP API sets this — always ``false`` for Finals created by the channel-1 automatic overwrite."},"manual_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Manual Description"},"own_documents_count":{"type":"integer","title":"Own Documents Count","default":0},"own_chars_count":{"type":"integer","title":"Own Chars Count","default":0},"cumulative_documents_count":{"type":"integer","title":"Cumulative Documents Count","default":0},"cumulative_chars_count":{"type":"integer","title":"Cumulative Chars Count","default":0},"content_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Hash","description":"Materialised SHA-256 hex over content fields EXCEPT ``enclosing_context`` (KD9 line 729 invariant)."},"approved_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Approved At","description":"NULL after every automatic Raw-overwrite; set by explicit approve. The third channel (top-down) does NOT reset this."},"enclosing_context_updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Enclosing Context Updated At","description":"When top-down last refreshed ``enclosing_context``. NULL on root (top-down skipped) and until the first top-down lands."},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","course_node_id","title","description","is_manual","content_hash","created_at","updated_at"],"title":"NodeSummaryFinalResponse","description":"Response shape for ``GET /api/v1/nodes/{node_id}/summary`` (P6, 200 branch).\n\nFull ``NodeSummaryFinal`` contract per KD11 §1043-1071. Read-only\non enclosing_context (refreshed by the third channel — top-down,\nNOT author-editable). The approval pair (``approved_at``,\n``enclosing_context_updated_at``) is two timestamps, not one\nderived field — downstream MUST NOT assume the whole Final was\napproved in a single moment (KD11 line 1069)."},"NodeSummaryFinalUpdateRequest":{"properties":{"title":{"anyOf":[{"type":"string","maxLength":500,"minLength":1},{"type":"null"}],"title":"Title"},"description":{"anyOf":[{"type":"string","maxLength":10000},{"type":"null"}],"title":"Description"},"learning_objectives":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Learning Objectives"},"knowledge":{"anyOf":[{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array"},{"type":"null"}],"title":"Knowledge"},"skills":{"anyOf":[{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array"},{"type":"null"}],"title":"Skills"},"success_criteria":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Success Criteria"},"assessment_approach":{"anyOf":[{"type":"string","maxLength":10000},{"type":"null"}],"title":"Assessment Approach"},"teaching_approach":{"anyOf":[{"type":"string","maxLength":10000},{"type":"null"}],"title":"Teaching Approach"},"key_activities":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Key Activities"},"common_mistakes":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Common Mistakes"}},"additionalProperties":false,"type":"object","title":"NodeSummaryFinalUpdateRequest","description":"Request body for ``PATCH /api/v1/node-summaries/{node_id}/final``.\n\nEditable family per KD11 §1043-1054 (Identity + Learning outcomes\n+ Assessment + Methodology). Pydantic rejects unknown keys via\n``extra='forbid'`` — concepts / ``enclosing_context`` / hash /\ntimestamps / ``is_manual`` / ``manual_description`` all 422 at the\nrequest-parsing boundary. The repository's deep ``ValueError`` gate\nis the defence-in-depth fallback.\n\nAll fields are optional — only present keys land on the row.\n``approved_at`` is NOT touched by PATCH (KD11: author edit does\nnot auto-invalidate a prior approval; explicit approve is a\nseparate action)."},"NodeSummaryGenerateRequest":{"properties":{"force":{"type":"boolean","title":"Force","description":"When true, the run proceeds even if scope-validation flags ancestors as uncovered_stale. Memo-skip on both hash axes is unconditional; force only widens what the API accepts.","default":false}},"type":"object","title":"NodeSummaryGenerateRequest","description":"Request body for ``POST /api/v1/nodes/{node_id}/summary/generate``.\n\nTriggers the two-pass methodist run via ARQ (orchestrator runs in\nthe worker; the route returns immediately with the queued Job).\n\n``force`` widens scope only for the API's 422 decision on\n``uncovered_stale_node_ids`` — memo-skip on both axes is\nunconditional inside the orchestrator (K1 ratify, Phase 3.2.2)."},"NodeTreeResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Unique node identifier (UUIDv7)."},"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id","description":"Tenant this node belongs to."},"parent_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Parent Id","description":"Parent node ID, or ``null`` for root nodes."},"title":{"type":"string","title":"Title","description":"Node title."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Optional node description."},"order":{"type":"integer","title":"Order","description":"0-based position among siblings."},"content_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Hash","description":"Merkle hash of this node's content. ``null`` if not computed."},"children":{"items":{"$ref":"#/components/schemas/NodeTreeResponse"},"type":"array","title":"Children","description":"Child nodes, recursively nested. Empty list for leaf nodes."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this node was created."},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"When this node was last modified."}},"type":"object","required":["id","tenant_id","parent_id","title","description","order","content_hash","created_at","updated_at"],"title":"NodeTreeResponse","description":"Recursive tree node with nested children.\n\nReturned by ``GET /nodes/{node_id}/tree``. Each node\ncontains its children, forming a full tree structure."},"NodeUpdateRequest":{"properties":{"title":{"anyOf":[{"type":"string","maxLength":500,"minLength":1},{"type":"null"}],"title":"Title","description":"New title for the node. Omit to keep unchanged.","examples":["Updated Module Title"]},"description":{"anyOf":[{"type":"string","maxLength":5000},{"type":"null"}],"title":"Description","description":"New description. Send ``null`` to clear, omit to keep unchanged. Note: distinguishing 'omit' from ``null`` requires checking ``model_fields_set``."},"default_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Language","description":"New default language for this subtree (any standard form; stored as canonical ISO 639-3). Send ``null`` to clear (children only — root rejects null with 422). Setting it is a pure DB write; does NOT trigger re-ingestion of existing materials.","examples":["uk","ukr"]}},"type":"object","title":"NodeUpdateRequest","description":"Request body for updating a material tree node.\n\nAll fields are optional — only provided fields are updated.\nTo clear the description, send ``\"description\": null`` explicitly.\n\n``default_language`` membership against ``config/languages.yaml`` is\nenforced here when present; the route additionally rejects\nset-to-null on a *root* node (HTTP 422) so the CHECK constraint\n``course_nodes_root_language_required`` never has to fire as a 500.\n\nExample::\n\n    {\"title\": \"Updated Title\"}"},"NodeWithDocumentsResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Unique node identifier (UUIDv7)."},"title":{"type":"string","title":"Title","description":"Node title."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Optional node description."},"default_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Language","description":"Canonical ISO 639-3 language for materials under this subtree. Root nodes (``parent_id`` is null) are guaranteed non-null by the ``course_nodes_root_language_required`` CHECK constraint; child nodes may be null (inheritance is resolved at ingestion from the root, not stored)."},"order":{"type":"integer","title":"Order","description":"0-based position among siblings."},"content_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Hash","description":"Merkle hash of this node's content. ``null`` if not computed."},"summary_status":{"type":"string","enum":["none","draft","approved"],"title":"Summary Status","description":"Methodist summary badge state (Task 3.2.5b): ``none`` (no summary generated), ``draft`` (generated, not yet approved), ``approved`` (author-approved). Computed per-node in the tree-feed — orthogonal to ``materials_changed``.","default":"none"},"materials_changed":{"type":"boolean","title":"Materials Changed","description":"Axis-1 staleness ONLY: the node's materials changed after the summary was generated (``Raw.source_content_hash`` differs from the live ``CourseNode.content_hash``). NOT the generate-route's two-axis ``uncovered_stale`` — the label is \"materials changed\", not \"needs regeneration\". Independent of ``summary_status`` (an approved node can still be ``materials_changed``).","default":false},"authored_documents":{"items":{"$ref":"#/components/schemas/AuthoredDocumentSummaryResponse"},"type":"array","title":"Authored Documents","description":"Authored documents attached directly to this node."},"children":{"items":{"$ref":"#/components/schemas/NodeWithDocumentsResponse"},"type":"array","title":"Children","description":"Child nodes, recursively nested."},"created_at":{"type":"string","format":"date-time","title":"Created At","description":"When this node was created."},"updated_at":{"type":"string","format":"date-time","title":"Updated At","description":"When this node was last modified."}},"type":"object","required":["id","title","description","order","content_hash","created_at","updated_at"],"title":"NodeWithDocumentsResponse","description":"Recursive tree node with attached materials.\n\nUsed in tree detail to provide the full hierarchical view\nincluding materials at each level."},"PortalAttemptResult":{"properties":{"score":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Score","description":"Final 0-100 grade, if reviewed."},"verdict":{"anyOf":[{"$ref":"#/components/schemas/PortalVerdict"},{"type":"null"}]}},"type":"object","title":"PortalAttemptResult","description":"A single attempt's curated outcome (Phase 6 T4a c2 overlay).\n\nCarried by ``last`` and ``best`` in the task overlay. Only the score and\nthe curated verdict: the internal trace is never here, and neither is the\nreview structure — an overlay is a summary, and the structure is the whole\nreview, which belongs in the detail view."},"PortalBaseDownload":{"properties":{"original_url":{"type":"string","title":"Original Url","description":"Fresh presigned GET of the active (latest READY) base original archive."}},"type":"object","required":["original_url"],"title":"PortalBaseDownload","description":"Presigned download of a project task's active base ORIGINAL (KD18 P5).\n\nThe student downloads the AUTHOR's original base archive (not the normalized\nsnapshot, KD17) to build their submission. Served on demand by the portal\nbase route so the tree descriptor stays cheap — no presigned URL is baked\ninto every task node. The active base is the latest READY version."},"PortalCourseListItem":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"title":{"type":"string","title":"Title","description":"Course (root CourseNode) title."}},"type":"object","required":["id","title"],"title":"PortalCourseListItem","description":"One enrolled course on the portal landing list (Phase 6 T4a c1).\n\nBare by design — no counts, no peeking into the tree: the first screen is\ncheap, and the material tree is a separate per-course call. Enrollment IS\nthe visibility gate (publish-gate A, KD17): the list is exactly the\nstudent's active enrollments.\n\nThe course language is NOT here even though this is the course card: it is\nread where it is used, and the submission form that needs it lives on the\nmaterial tree, so ``PortalMaterialTreeNode.default_language`` carries it\n(step Д) rather than a second screen holding a value for the first one."},"PortalDeltaReceipt":{"properties":{"changed":{"type":"integer","title":"Changed","description":"Files present in both base and submission with different content."},"new":{"type":"integer","title":"New","description":"Files added relative to the base."},"deleted":{"type":"integer","title":"Deleted","description":"Base files absent from the submission."},"base_version":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Base Version","description":"Version of the base this submission was built on; null when no base was attached."},"latest_version":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Latest Version","description":"Latest READY base version now; null when no base was attached."},"is_stale":{"type":"boolean","title":"Is Stale","description":"True when a newer READY base exists than the one built on (a signal, not a blocker)."}},"type":"object","required":["changed","new","deleted","is_stale"],"title":"PortalDeltaReceipt","description":"I2 delta counters + staleness for a project submission (KD18 P5).\n\nDerived on read (KD-P3-B) from the persisted base + submission manifests —\nthe DB stores manifests, not counts, and ``compute_delta`` is BE-only. The\nstudent sees how their submission diverged from the base and whether the\nbase has since moved on.\n\nNull on the parent detail means there is no base to compare against: the\nsubmission is not a project one, or its task carries no base. Both are a\nDISTINCT state from an all-zero delta, which says a comparison ran and\nfound nothing changed.\n\nThe hygiene level (normalizer-excluded new files) is deliberately NOT\nsurfaced to the student."},"PortalLoginRequest":{"properties":{"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id","description":"Tenant the student belongs to."},"login":{"type":"string","maxLength":200,"minLength":1,"title":"Login","description":"Student login."},"password":{"type":"string","minLength":1,"title":"Password","description":"Student password (plaintext)."}},"type":"object","required":["tenant_id","login","password"],"title":"PortalLoginRequest","description":"Request body for POST /portal/login.\n\nThe portal is tenant-scoped and the login lookup happens before the\nstudent is known, so the tenant is resolved explicitly from ``tenant_id``\n(KD17 ratify Q2) rather than guessed."},"PortalLoginResponse":{"properties":{"access_token":{"type":"string","title":"Access Token","description":"HS256 bearer session token."},"token_type":{"type":"string","title":"Token Type","description":"Always ``bearer``.","default":"bearer"},"student_id":{"type":"string","format":"uuid","title":"Student Id","description":"Authenticated student ID."},"display_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Display Name","description":"Student's portal display name, if set."}},"type":"object","required":["access_token","student_id"],"title":"PortalLoginResponse","description":"Response for a successful POST /portal/login."},"PortalMaterialItem":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"kind":{"type":"string","enum":["material","task"],"title":"Kind","description":"``task`` if the document is a submittable assignment (task_type IS NOT NULL), else ``material``."},"label":{"type":"string","title":"Label","description":"Display label: the filename, else ``{source_type} #{order}``."},"source_type":{"type":"string","title":"Source Type","description":"video / presentation / text / web / audio."},"order":{"type":"integer","title":"Order","description":"0-based position among sibling documents."},"task_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Task Type","description":"Assignment type when ``kind=task`` (e.g. ``project``); null for a material."},"base":{"anyOf":[{"$ref":"#/components/schemas/PortalTaskBase"},{"type":"null"}],"description":"Active base descriptor (KD18 P5) — present only for a project task WITH a base; null for a base-less project task, a non-project task, or a material."},"overlay":{"anyOf":[{"$ref":"#/components/schemas/PortalSubmissionOverlay"},{"type":"null"}],"description":"Submission overlay — present iff ``kind=task``, else null."},"test_form":{"type":"boolean","title":"Test Form","description":"True when this task is a test answered with the test form (task 07): show the questions of GET /portal/tasks/{id}/test instead of a file upload. False for every other document — and for a test while tests are still answered with a file.","default":false}},"type":"object","required":["id","kind","label","source_type","order"],"title":"PortalMaterialItem","description":"One document in the course tree — a reading material or a task.\n\n``kind`` discriminates (mirroring the T3 descriptor so the FE consumes one\nflat contract): ``material`` (``task_type`` IS NULL) carries no overlay;\n``task`` (``task_type`` IS NOT NULL) carries the submission overlay.\n``task_type`` and ``base`` (KD18 P5) let the portal render the project\naffordance (base download + echo) without a second call."},"PortalMaterialTreeNode":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"title":{"type":"string","title":"Title","description":"Node title."},"order":{"type":"integer","title":"Order","description":"0-based position among siblings."},"default_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Default Language","description":"Course language (ISO 639-3) — set on the ROOT of the tree, null on every child."},"documents":{"items":{"$ref":"#/components/schemas/PortalMaterialItem"},"type":"array","title":"Documents","description":"READY documents directly on this node (materials + tasks)."},"children":{"items":{"$ref":"#/components/schemas/PortalMaterialTreeNode"},"type":"array","title":"Children","description":"Child nodes, recursively nested."}},"type":"object","required":["id","title","order"],"title":"PortalMaterialTreeNode","description":"Recursive course-tree node, curated for the portal (Phase 6 T4a c2).\n\nNarrower than the author-facing ``NodeWithDocumentsResponse``: only\nid / title / order + children + documents (no content_hash, summary_status,\nper-node language, timestamps) — with one addition in step Д, the COURSE\nlanguage on the root, because the submission form that needs it lives on\nthis tree and nowhere else. Soft-deleted nodes / documents and non-READY\ndocuments are filtered out before projection (publish-gate A + READY-only)."},"PortalMeResponse":{"properties":{"student_id":{"type":"string","format":"uuid","title":"Student Id"},"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id"},"login":{"type":"string","title":"Login"},"display_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Display Name"},"recovery_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recovery Email"},"recovery_email_confirmed":{"type":"boolean","title":"Recovery Email Confirmed","default":false},"preferred_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preferred Language","description":"Canonical ISO 639-3 language the student last asked a review in, or null if they never asked. The submission form opens on it, so a standing preference is visible instead of only remembered."}},"type":"object","required":["student_id","tenant_id","login"],"title":"PortalMeResponse","description":"Response for GET /portal/me (the authenticated student's identity).\n\nThe recovery-email status is folded in (Phase 6 R2) rather than exposed on\na separate GET: ``recovery_email`` is the current address (or None), and\n``recovery_email_confirmed`` reflects whether it has been verified. A\nforgot-password link is only ever sent to a confirmed address."},"PortalMediaResponse":{"properties":{"kind":{"type":"string","enum":["external","file","slides"],"title":"Kind","description":"Render mode the portal branches on."},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url","description":"External link (kind=external) or presigned-GET URL (kind=file). None for kind=slides."},"slide_urls":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Slide Urls","description":"Ordered presigned-GET URLs for the slide WebPs (kind=slides only; empty list for a pre-T3 presentation). None otherwise."}},"type":"object","required":["kind"],"title":"PortalMediaResponse","description":"How to render a material to the student, originals-only (Phase 6 T3).\n\nA single descriptor the portal SPA branches on (KD17). ``kind``:\n\n- ``external`` — YouTube/web: ``url`` is the original external link\n  (never proxied through S3).\n- ``file`` — uploaded video/audio: ``url`` is a fresh presigned-GET on the\n  object (flat 300-min TTL, KD17).\n- ``slides`` — presentation: ``slide_urls`` is the ordered list of fresh\n  presigned-GET URLs for the persisted WebP renders (empty for a\n  presentation ingested before T3 — never an error).\n\nDeliberately minimal and forward-extensible (T4 informs the final shape)."},"PortalNotOpened":{"properties":{"path":{"type":"string","title":"Path","description":"Name of the file inside the archive."},"reason":{"type":"string","title":"Reason","description":"Why it was not read (ErrorCategory value)."},"size":{"type":"integer","title":"Size","description":"Size in bytes, as declared by the archive."}},"type":"object","required":["path","reason","size"],"title":"PortalNotOpened","description":"One file from the submission the checker did not read, and why.\n\nShown to the student on both a passing and a refused attempt: a review\nthat quietly rested on part of the work is the thing this exists to\nprevent. ``reason`` is the same code vocabulary as\n:class:`PortalRejection`, so the interface keeps one dictionary."},"PortalPresentation":{"properties":{"state":{"type":"string","enum":["not_opened","not_an_attempt","awaiting_funds","in_progress","reviewed"],"title":"State","description":"What to tell the student about this attempt."},"reason_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason Code","description":"Service key the surface phrases; null when the state is the whole answer."}},"type":"object","required":["state"],"title":"PortalPresentation","description":"What to SAY about an attempt: one of five states, and why (task 03).\n\nThe server's single answer, so the tree, the attempts list and the detail\nview cannot disagree about the same attempt — and so the interface stops\nkeeping a second copy of the rule (``DD-SP-AS`` records the old copies).\n\nFive states, because that is how many different things a student can be\ntold: it was not opened, it did not look like an attempt, it is waiting for\nthe account to be funded, it is being checked, it has been reviewed. The\neleven stored lifecycle statuses map onto them; which one it was is an\ninternal fact and stays one (language-rules).\n\n``reason_code`` is a service key, not a sentence: the surface picks its own\nwords for it, and the same code may be phrased differently to a student and\nto an author. ``None`` where the state says everything there is to say."},"PortalRejection":{"properties":{"code":{"type":"string","title":"Code","description":"Reason code (ErrorCategory value or status)."},"details":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Details","description":"Curated short specifics — never the trace."}},"type":"object","required":["code"],"title":"PortalRejection","description":"Why an attempt was refused, in terms the interface can phrase.\n\nCarries the CODE, never the internal message. ``DD-6-D`` forbade exposing\nthe trace and that stands: ``error_message`` is a developer string with\nlibrary vocabulary in it, and it is not serialized here or anywhere on the\nportal. The review of that ban (ratified 2026-09-02) was narrow — the\nprohibition is on technical detail, not on telling the student what\nhappened, and a stable code is what lets the interface say it in their\nlanguage.\n\n``details`` is only the short curated fact the phrase cannot know by\nitself, such as which file it was."},"PortalSubmissionDetail":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"status":{"type":"string","title":"Status","description":"Lifecycle milestone (also the progress signal)."},"presentation":{"$ref":"#/components/schemas/PortalPresentation","description":"What to say about this attempt (mentor-rebuild task 03), beside ``status`` and never instead of it."},"score":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Score","description":"Final 0-100 grade, if any."},"verdict":{"anyOf":[{"$ref":"#/components/schemas/PortalVerdict"},{"type":"null"}]},"structure":{"anyOf":[{"$ref":"#/components/schemas/ReviewStructureV1"},{"type":"null"}],"description":"The review as data, in the student's language (schema version 1). Written by the new path's result builders — a test's, and a text task's once its type is switched; null for a pre-rebuild review, which has no structure to project."},"review_markdown":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Review Markdown","description":"Rendered Markdown review (None until reviewed)."},"created_at":{"type":"string","format":"date-time","title":"Created At"},"original_filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Original Filename"},"delta":{"anyOf":[{"$ref":"#/components/schemas/PortalDeltaReceipt"},{"type":"null"}],"description":"I2 delta receipt (KD18 P5) for a project submission — counters + staleness; null for a non-project submission."},"own_feedback":{"anyOf":[{"$ref":"#/components/schemas/FeedbackTouch"},{"type":"null"}],"description":"This student's own answer about this review (task 05); null when they have not answered, and on a submission that carries no review to answer about. Nobody else's answer is ever served here."},"rejection":{"anyOf":[{"$ref":"#/components/schemas/PortalRejection"},{"type":"null"}],"description":"Why the attempt was refused; null if it was not."},"not_opened":{"items":{"$ref":"#/components/schemas/PortalNotOpened"},"type":"array","title":"Not Opened","description":"Files skipped during checking — also on a passing attempt."},"recovered_encoding":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recovered Encoding","description":"How the submitted file was read: ``utf-8`` when its bytes decoded directly, another encoding name when recovery established one and the review was written from that reading, or null when the question does not apply (an archive recovers its members individually; a document arrives already decoded)."}},"type":"object","required":["id","status","presentation","created_at"],"title":"PortalSubmissionDetail","description":"One attempt — the full curated slice (Phase 6 T2 read-path detail).\n\nThe internal trace is NEVER serialized: not ``safety_result``, not\n``sanity_result``, and not the layered ``review_result`` of a pre-rebuild\nreview, which is the Mentor's own working notes. That rule is about the\ntrace, not about the column. From schema version 1 the same column holds a\nreview structure instead, and a structure is not a trace: every field of it\nis written to be read by the student. It goes out whole, in ``structure``.\nWhich of the two a row holds is answered by the version key inside it.\n\nA project submission also carries the ``delta`` receipt (KD18 P5); it is\nnull for a non-project one."},"PortalSubmissionListItem":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"status":{"type":"string","title":"Status","description":"Lifecycle milestone (also the progress signal)."},"presentation":{"$ref":"#/components/schemas/PortalPresentation","description":"What to say about this attempt (mentor-rebuild task 03). Beside ``status``, never instead of it — the milestone stays on the wire for whoever already reads it (``DD-SP-AS``)."},"score":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Score","description":"Final 0-100 grade, if any."},"verdict":{"anyOf":[{"$ref":"#/components/schemas/PortalVerdict"},{"type":"null"}]},"created_at":{"type":"string","format":"date-time","title":"Created At"},"original_filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Original Filename"},"rejection":{"anyOf":[{"$ref":"#/components/schemas/PortalRejection"},{"type":"null"}],"description":"Why the attempt was refused; null if it was not."},"not_opened":{"items":{"$ref":"#/components/schemas/PortalNotOpened"},"type":"array","title":"Not Opened","description":"Files skipped during checking — also on a passing attempt."},"recovered_encoding":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recovered Encoding","description":"How the submitted file was read: ``utf-8`` when its bytes decoded directly, another encoding name when recovery established one and the review was written from that reading, or null when the question does not apply (an archive recovers its members individually; a document arrives already decoded)."}},"type":"object","required":["id","status","presentation","created_at"],"title":"PortalSubmissionListItem","description":"One attempt in the read-path list (Phase 6 T2). NO review_markdown.\n\nThe list is intentionally light: the heavy ``review_markdown`` is fetched\nonly in the detail view, and so is the review structure. The internal trace\n(a pre-rebuild ``review_result``, ``safety_result``, ``sanity_result``) is\nNEVER included — ``not_opened`` and ``recovered_encoding`` are curated\nprojections OF that trace, two facts about how the file was read, not the\ntrace itself."},"PortalSubmissionOverlay":{"properties":{"submission_status":{"type":"string","enum":["none","pending","reviewed","error"],"title":"Submission Status","description":"By the LATEST attempt: ``none`` (no attempts) / ``pending`` (in flight: received / safety_ok / sanity_ok / reviewing) / ``reviewed`` (a review is written: completed / delivered) / ``error`` (a terminal error: rejected / mismatch / failed). The tree carries only this coarse ``error`` bucket — the precise terminal is the read-path detail's concern."},"presentation":{"anyOf":[{"$ref":"#/components/schemas/PortalPresentation"},{"type":"null"}],"description":"What to say about the LATEST attempt (mentor-rebuild task 03) — the same answer the attempts list carries for it. Beside ``submission_status``, never instead of it (``DD-SP-AS``). Null when there are no attempts."},"last":{"anyOf":[{"$ref":"#/components/schemas/PortalAttemptResult"},{"type":"null"}],"description":"The latest attempt's curated result (present iff ≥1 attempt)."},"best":{"anyOf":[{"$ref":"#/components/schemas/PortalAttemptResult"},{"type":"null"}],"description":"The highest-scored REVIEWED attempt with a non-null score (pending / null-score attempts never compete). Absent when no attempt has a usable score yet; equals ``last`` when there is exactly one."}},"type":"object","required":["submission_status"],"title":"PortalSubmissionOverlay","description":"Per-task submission overlay for the materials tree (Phase 6 T4a c2).\n\nComputed from the student's OWN active (non-deleted) submissions on the\ntask — one query per course, grouped in Python (no N+1)."},"PortalSubmitResponse":{"properties":{"submission_id":{"type":"string","format":"uuid","title":"Submission Id","description":"The created (or duplicate) submission."},"status":{"type":"string","title":"Status","description":"Submission status ('received' for a fresh submission; the existing status when a duplicate was returned)."},"duplicate":{"type":"boolean","title":"Duplicate","description":"True when an identical file for this task was already submitted — the existing submission is returned, no new review runs.","default":false}},"type":"object","required":["submission_id","status"],"title":"PortalSubmitResponse","description":"Response for a portal session submission (Phase 6 T2, mode-2 in_app).\n\nThe student tracks progress + reads the review via the read-path (status on\nthe same endpoint), so the response is minimal: no caller-facing job_id, no\nstudent_id (it is the session's own student)."},"PortalTaskBase":{"properties":{"version":{"type":"integer","title":"Version","description":"1-based version of the described base."},"snapshot_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Snapshot Hash","description":"Echo-match hash of the active READY base (the portal sends it back automatically on submit); null unless ``state`` is ``ready``."},"state":{"type":"string","enum":["pending","ready","failed"],"title":"State","description":"Lifecycle of the described base version."}},"type":"object","required":["version","state"],"title":"PortalTaskBase","description":"Active base-archive descriptor for a project task (KD18 P5).\n\nAttached to a project ``PortalMaterialItem`` when a base version exists.\n``state == 'ready'`` describes the active (latest READY) version: the\nstudent may submit and ``snapshot_hash`` is the auto-echo source the portal\nsends back on submit. ``pending`` / ``failed`` describes the latest version\nwhen none is READY yet — submit is blocked and ``snapshot_hash`` is null.\n\nA project task with NO base attached carries ``base = null`` on the item —\na DISTINCT state (submit allowed, everything is new), never collapsed into\na non-ready ``state``."},"PortalTestSubmitRequest":{"properties":{"answers":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","title":"Answers","description":"The answers: question number → the labels of the options chosen, as the test prints them (``{\"1\": [\"B\"], \"2\": [\"A\", \"C\"]}``). Labels are compared in canonical form — case, spaces, a trailing ``)`` or ``.``, and Latin letters drawn like Cyrillic ones do not matter. A question left out is not refused: it counts as answered wrong."},"test_version":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Test Version","description":"The ``version`` of the test these answers were given to, as the structure route returned it. When it is not the test's current version the answers are refused with ``TEST_VERSION_CHANGED``; when omitted, the current version is assumed."},"response_language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Response Language","description":"Language of the review; ISO 639-1 or 639-3 accepted, stored as 639-3. If omitted: the student's stored preference, then the course language."},"student_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Student Note","description":"The student's comment on this attempt: up to 2000 characters, a line break counted as one and the edges trimmed. A longer comment is refused with ``STUDENT_NOTE_TOO_LONG``, one a text screen refuses (direction-changing, tag or control characters) with ``STUDENT_NOTE_REJECTED`` — both 422, before anything is stored."}},"additionalProperties":false,"type":"object","required":["answers"],"title":"PortalTestSubmitRequest","description":"Body of POST /portal/tasks/{id}/test-submissions (task 07)."},"PortalTouchRequest":{"properties":{"kind":{"$ref":"#/components/schemas/FeedbackKind","description":"The kind of feedback. Today: touch."},"value":{"$ref":"#/components/schemas/FeedbackValue","description":"The answer: helped or not_helped."}},"additionalProperties":false,"type":"object","required":["kind","value"],"title":"PortalTouchRequest","description":"What the student says about a review, as it arrives (task 05).\n\n``kind`` is spelled out although the vocabulary has one member: the second\nechelon adds members, and a body that names the kind reads the same before\nand after that, while a body that omits it would have to start naming it."},"PortalVerdict":{"properties":{"passed":{"type":"boolean","title":"Passed","description":"Whether the submission meets the bar."},"correctness":{"type":"string","title":"Correctness","description":"Coarse correctness (correct / partially_correct / incorrect)."}},"type":"object","required":["passed","correctness"],"title":"PortalVerdict","description":"Caller-facing verdict shown to the student (Phase 6 T2 read-path).\n\nFor a pre-rebuild review, derived from ``review_result['verdict']``; None\nuntil a review has written it. Its layered ``review_result`` is an internal\ntrace and never leaves the service — the ``verdict`` block is the one thing\ncurated out of it. A version-1 review stores a review structure in that\nsame column rather than a trace (see ``PortalSubmissionDetail.structure``);\nits verdict is the structure's own, true when it has none, and its\ncorrectness comes from the score (task 07, decision 14)."},"Position":{"properties":{"kind":{"type":"string","enum":["video","audio","slide","paragraph","file"],"title":"Kind","description":"Which of the five kinds of place."},"value":{"type":"string","minLength":1,"title":"Value","description":"The time, number or name itself, already formatted for reading ('03:41', '12', 'main.py'). The assembler puts it into the phrase's placeholder unchanged."}},"additionalProperties":false,"type":"object","required":["kind","value"],"title":"Position","description":"Where in the material something is, in the material's own terms.\n\nNot validated against the material: the structure is written once and read\nlater, by which time the material may have moved. A position that no longer\nresolves is a stale reference (§2.12), handled where references are, not\nrefused here.\n\n>>> Position(kind=\"slide\", value=\"12\").kind\n'slide'"},"PresignedUrlRequest":{"properties":{"filename":{"type":"string","maxLength":500,"minLength":1,"title":"Filename","description":"Original filename (used for S3 key and extension validation)."},"content_type":{"type":"string","maxLength":200,"minLength":1,"title":"Content Type","description":"MIME type of the file to upload."},"source_type":{"$ref":"#/components/schemas/SourceType","description":"Material type (video, presentation, text, web, audio, code)."},"size_bytes":{"anyOf":[{"type":"integer","minimum":1.0},{"type":"null"}],"title":"Size Bytes","description":"File size in bytes (optional, for pre-validation)."}},"type":"object","required":["filename","content_type","source_type"],"title":"PresignedUrlRequest","description":"Request body for POST /nodes/{node_id}/documents/upload-url."},"PresignedUrlResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Presigned PUT URL for direct S3 upload."},"key":{"type":"string","title":"Key","description":"S3 object key to use in confirm-upload."},"expires_in":{"type":"integer","title":"Expires In","description":"URL validity in seconds."}},"type":"object","required":["upload_url","key","expires_in"],"title":"PresignedUrlResponse","description":"Response for POST /nodes/{node_id}/documents/upload-url."},"ProcessingEstimate":{"properties":{"max_hours":{"type":"number","title":"Max Hours","description":"Upper-bound processing time before the document is ready, hours."},"check_after_hours":{"type":"number","title":"Check After Hours","description":"Suggested delay before re-checking job status, hours."}},"type":"object","required":["max_hours","check_after_hours"],"title":"ProcessingEstimate","description":"Informational ingestion queue-wait hint (DD-3.3c-I-B).\n\nReturned on successful intake so the client can set expectations: the\nsingle serial worker drains the queue slowly on modest hardware. Both\nvalues are operator-tuned settings (``intake_hint_*``). The UI localizes\nthe surrounding copy; this carries only the numbers."},"ProjectBaseAttachResponse":{"properties":{"base_version_id":{"type":"string","format":"uuid","title":"Base Version Id","description":"The created ProjectBase version id."},"version":{"type":"integer","title":"Version","description":"1-based version number for this document."},"state":{"type":"string","title":"State","description":"Normalization state (``pending`` on attach)."}},"type":"object","required":["base_version_id","version","state"],"title":"ProjectBaseAttachResponse","description":"Response for POST /documents/{document_id}/base (KD18 P2).\n\nReturned with 202 — the base archive is stored and the deterministic\nnormalization is enqueued; the version starts ``pending`` and flips to\n``ready`` | ``failed`` in the background."},"ProjectBaseDescriptorResponse":{"properties":{"base_version_id":{"type":"string","format":"uuid","title":"Base Version Id","description":"The ProjectBase version id."},"version":{"type":"integer","title":"Version","description":"1-based version number."},"snapshot_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Snapshot Hash","description":"Aggregate snapshot hash (echo-match key); null until ready."},"state":{"type":"string","title":"State","description":"Normalization state: pending | ready | failed."},"original_url":{"type":"string","title":"Original Url","description":"Presigned GET URL of the original base archive."}},"type":"object","required":["base_version_id","version","state","original_url"],"title":"ProjectBaseDescriptorResponse","description":"Response for GET /homework/tasks/{id}/base (KD18 P2, mode-1 API-key).\n\nThe active base — the latest ``ready`` version — or, if none is ready yet,\nthe latest version with its ``state`` (``snapshot_hash`` is ``null`` until\nready). ``snapshot_hash`` is exposed by design: the P3 submit path echoes\nit as ``base_snapshot_hash``. ``original_url`` is a presigned GET of the\nauthor's original archive."},"ProjectBaseManifestResponse":{"properties":{"schema":{"type":"integer","title":"Schema"},"aggregate_hash":{"type":"string","title":"Aggregate Hash"},"included":{"items":{"$ref":"#/components/schemas/ManifestEntry"},"type":"array","title":"Included"},"excluded":{"items":{"$ref":"#/components/schemas/ExcludedEntry"},"type":"array","title":"Excluded"},"total_files":{"type":"integer","title":"Total Files"},"total_bytes":{"type":"integer","title":"Total Bytes"}},"type":"object","required":["schema","aggregate_hash","included","excluded","total_files","total_bytes"],"title":"Manifest"},"ProjectBaseStateResponse":{"properties":{"version":{"type":"integer","title":"Version","description":"1-based version number for this document."},"state":{"type":"string","title":"State","description":"Normalization state: pending | ready | failed."},"failure_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failure Reason","description":"Human-readable reason; non-null only when state='failed'."},"snapshot_hash":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Snapshot Hash","description":"Aggregate snapshot hash; null until state='ready'."}},"type":"object","required":["version","state"],"title":"ProjectBaseStateResponse","description":"Response for GET /documents/{document_id}/base (KD18 P6, author).\n\nThe author-monitoring read-surface: the LATEST base version REGARDLESS of\nstate (``get_latest``), deliberately NOT the mode-1 student descriptor's\nlatest-ready-else-latest. After a re-upload the author must see the fresh\nversion's ``pending`` / ``failed`` progress, not an older READY version\nmasking it — the opposite goal of ``GET /homework/tasks/{id}/base``, which\nneeds a *usable* READY base for the student. ``failure_reason`` is non-null\nonly for ``failed``; ``snapshot_hash`` is null until ``ready``."},"ProvisionStudentRequest":{"properties":{"mode":{"type":"string","enum":["generate","manual","existing"],"title":"Mode","description":"external_id resolution mode."},"external_id":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"External Id","description":"Required for mode='manual'; must be omitted for mode='generate'; ignored for mode='existing'."},"student_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Student Id","description":"Required for mode='existing' — the existing student to attach the credential to."},"login":{"type":"string","maxLength":200,"minLength":1,"title":"Login","description":"Portal login."},"password":{"type":"string","minLength":1,"title":"Password","description":"Portal password (plaintext; only the argon2id hash is stored). Must meet the minimum-length policy."},"display_name":{"anyOf":[{"type":"string","maxLength":200},{"type":"null"}],"title":"Display Name","description":"Portal display name."}},"type":"object","required":["mode","login","password"],"title":"ProvisionStudentRequest","description":"Request body for POST /students (tenant-admin provisioning, scope PREP).\n\n``external_id`` has three modes (KD17 ratify Q2 refinement B):\n\n* ``generate`` — the system mints a fresh ``external_id`` (omit the field);\n* ``manual``   — caller supplies ``external_id`` (must be new in the tenant);\n* ``existing`` — caller supplies ``student_id`` of an existing student; the\n  credential is attached to it (the integration-mode student gains a portal\n  login and its prior history). No new student row is created."},"ProvisionStudentResponse":{"properties":{"student_id":{"type":"string","format":"uuid","title":"Student Id"},"external_id":{"type":"string","title":"External Id"},"login":{"type":"string","title":"Login"}},"type":"object","required":["student_id","external_id","login"],"title":"ProvisionStudentResponse","description":"Response for a successful POST /students (201)."},"RecoveryEmailRequest":{"properties":{"email":{"type":"string","maxLength":320,"minLength":3,"title":"Email","description":"Student-owned recovery email to set or change."}},"type":"object","required":["email"],"title":"RecoveryEmailRequest","description":"Request body for POST /portal/recovery-email (set/change; protected).\n\nThe email is validated only minimally — a single ``@`` with a non-empty\nlocal part and domain (RP6): deliverability is proven by the confirm flow\n(the mail must arrive and its token be redeemed), not a strict RFC\nvalidator, so no ``email-validator`` dependency is pulled in. ``max_length``\nmirrors the ``String(320)`` column so an overflow is a 422, not a DB error."},"RecoveryEmailResponse":{"properties":{"recovery_email":{"type":"string","title":"Recovery Email","description":"The address now on file."},"recovery_email_confirmed":{"type":"boolean","title":"Recovery Email Confirmed","description":"Always False right after set/change — a confirm mail was sent."}},"type":"object","required":["recovery_email","recovery_email_confirmed"],"title":"RecoveryEmailResponse","description":"Response for POST /portal/recovery-email (the new pending state)."},"Reference":{"properties":{"title":{"type":"string","minLength":1,"title":"Title","description":"What the student will read."},"url":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Url","description":"Where it is, resolved by code. None for a course link, whose address the portal builds from segment_id."},"position":{"anyOf":[{"$ref":"#/components/schemas/Position"},{"type":"null"}],"description":"Where in the material, for a course link that has a place."},"excerpt":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Excerpt","description":"A few words of the fragment, for the portal to show. Not printed in the markdown."},"segment_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Segment Id","description":"The fragment of the course material the link points at."},"later_in_course":{"type":"boolean","title":"Later In Course","description":"True when the fragment comes later in the course than the task, which the review then says.","default":false}},"additionalProperties":false,"type":"object","required":["title"],"title":"Reference","description":"Something to read: an outside link, or a course link to a place in the course.\n\nThe model names an identifier from its input; code turns it into this\n(§2.12), so the assembler never has to decide what to do with a bare id.\n\nA course link (task 10) points at a fragment of a course material: the\nmaterial's title, where in it (``position``), the fragment's identifier and\na short excerpt. Its address in the portal is not this service's to know —\nthe portal builds it from ``segment_id`` — so ``url`` is optional, and a\nlink without one is printed as plain text. ``excerpt`` is for the portal to\nshow; the markdown leaves it out.\n\nEvery field added by task 10 has a default, so a review stored before them\nreads back unchanged. What a reference points at is not optional: it has\nan address (``url``) or a fragment of the course (``segment_id``), or it\nis refused (task 10, K6) — a title alone sends the student nowhere. A\nreview stored before task 10 has an address on every reference, so it\npasses as before.\n\n>>> Reference(title=\"Змінні середовища\",\n...           position=Position(kind=\"audio\", value=\"12:05\"),\n...           segment_id=\"0192f6a0-0000-7000-8000-000000000001\",\n...           later_in_course=True).url is None\nTrue\n>>> Reference(title=\"Змінні середовища\")\nTraceback (most recent call last):\n    ...\npydantic_core._pydantic_core.ValidationError: ...points somewhere..."},"ReferenceKeyUpdateRequest":{"properties":{"answers":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","minProperties":1,"title":"Answers","description":"The author's answers: question number → the option labels that are right. A set of labels, not one label — how a multi-label question scores is task 07's business, but the shape must not need a migration then."},"author_explanations":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Author Explanations","description":"The author's own explanations for individual questions. Outside the generation key: editing one does not buy a fresh generation."},"pass_threshold":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Pass Threshold","description":"The pass mark: the percentage of questions a student must answer right for the test to count as passed (task 07). Replaced with the rest of the key, so a request without it clears it — and a review then states the score and no verdict. Outside the generation key: changing it buys no generation."}},"additionalProperties":false,"type":"object","required":["answers"],"title":"ReferenceKeyUpdateRequest","description":"Body of PUT /documents/{id}/reference/override (mentor-rebuild task 06).\n\nThe author's key, whole. There is no partial form on purpose (ratified\n2026-09-19): a key is answered as a set, and a patch would leave states\nwhere half the questions answer an older version of the test."},"ReferenceViewResponse":{"properties":{"status":{"type":"string","title":"Status","description":"How far the CURRENT version got: ``awaiting_key`` (no usable key), ``generating``, ``ready`` or ``failed``."},"version":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Version","description":"1-based version number, or null when none exists."},"answers":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","title":"Answers","description":"The author's answers, as stored."},"explanations":{"additionalProperties":{"type":"string"},"type":"object","title":"Explanations","description":"One explanation per question — the author's own where they wrote one, the generated one otherwise. The author's own are here at once; the generated ones only from ``ready``."},"carried_over":{"type":"boolean","title":"Carried Over","description":"True when these answers reached the current task version automatically because its question numbers were unchanged.","default":false},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language","description":"Language the explanations are written in."},"failure_reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Failure Reason","description":"Why generation gave up, when ``failed``."},"pass_threshold":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Pass Threshold","description":"The pass mark the author set, 1-100 (task 07); null when there is none."},"doubts":{"additionalProperties":{"type":"boolean"},"type":"object","title":"Doubts","description":"Questions on which the model doubts the author's answer, ``{question: true}`` (task 07). Students are not shown the generated explanation of a doubted question; the author's own explanation of it they are. Empty when the model doubts nothing, until ``ready``, and for explanations written before doubts existed."}},"type":"object","required":["status"],"title":"ReferenceViewResponse","description":"What the author sees about a task's reference (mentor-rebuild task 06).\n\nReturned by all three routes, so the author always reads the same shape —\nafter writing a key, after clearing one, and when merely looking."},"Remark":{"properties":{"what":{"type":"string","minLength":1,"title":"What","description":"What is wrong."},"why":{"type":"string","minLength":1,"title":"Why","description":"Why it matters."},"todo":{"type":"string","minLength":1,"title":"Todo","description":"What to do about it."},"read":{"items":{"$ref":"#/components/schemas/Reference"},"type":"array","title":"Read","description":"Where to read more. Empty is normal: not every remark needs it."},"position":{"anyOf":[{"$ref":"#/components/schemas/Position"},{"type":"null"}],"description":"Where in the material this is, when the remark has a place."}},"additionalProperties":false,"type":"object","required":["what","why","todo"],"title":"Remark","description":"One thing to say about the work, in the contrasting form of §2.13.\n\nFour parts, all required except the reading: what is wrong, why it matters,\nwhat to do, where to read about it. The form is the point -- a remark that\nsays what is wrong without saying why leaves the student to guess whether it\nmatters, and one without a ``todo`` leaves them with a verdict instead of a\nlesson.\n\n>>> Remark(what=\"Пароль написано просто текстом.\",\n...        why=\"Його побачить кожен, хто відкриє файл.\",\n...        todo=\"Перенести пароль до змінної середовища.\").read\n[]"},"Reply":{"properties":{"kind":{"type":"string","enum":["question","objection","comment"],"title":"Kind","description":"Question, objection or comment."},"said":{"type":"string","minLength":1,"title":"Said","description":"What the student wrote."},"answer":{"type":"string","minLength":1,"title":"Answer","description":"The Mentor's answer to it."}},"additionalProperties":false,"type":"object","required":["kind","said","answer"],"title":"Reply","description":"An answer to something the student said, with what they said kept beside it.\n\n``said`` is stored rather than assumed known, so a review read a year later\nstill makes sense on its own -- the same reason the language is stored."},"ResetPasswordRequest":{"properties":{"token":{"type":"string","minLength":1,"title":"Token","description":"The password-reset token."},"password":{"type":"string","minLength":1,"title":"Password","description":"New password (plaintext)."}},"type":"object","required":["token","password"],"title":"ResetPasswordRequest","description":"Request body for POST /portal/password/reset (public).\n\n``password`` carries the schema floor only (``min_length=1``); the real\nminimum-length policy is enforced at hash time (argon2id), mapping a weak\npassword to 422 — mirroring provisioning."},"ReviewStructureV1":{"properties":{"schema_version":{"type":"string","const":"1","title":"Schema Version"},"language":{"type":"string","title":"Language","description":"The language this review is written in, ISO 639-3, as ``language.resolve_review_language`` resolved it. Part of the review because the same structure must give the same text later; not checked against the allowed list, which may have changed by the time this is read again."},"verdict":{"anyOf":[{"$ref":"#/components/schemas/Verdict"},{"type":"null"}],"description":"Passed or not, and why."},"test":{"anyOf":[{"$ref":"#/components/schemas/TestSection"},{"type":"null"}],"description":"A test's score and its questions, one by one."},"fixed":{"items":{"$ref":"#/components/schemas/Remark"},"type":"array","title":"Fixed","description":"Remarks from before that are now dealt with."},"new_remarks":{"items":{"$ref":"#/components/schemas/Remark"},"type":"array","title":"New Remarks","description":"What this submission newly gives cause for."},"open":{"items":{"$ref":"#/components/schemas/Remark"},"type":"array","title":"Open","description":"Remarks from before that still stand."},"broken":{"items":{"$ref":"#/components/schemas/Remark"},"type":"array","title":"Broken","description":"What used to work and does not now."},"mentor_voice":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mentor Voice","description":"The Mentor speaking as itself, kept in its own block."},"replies":{"items":{"$ref":"#/components/schemas/Reply"},"type":"array","title":"Replies","description":"Answers to what the student wrote."},"verification":{"anyOf":[{"$ref":"#/components/schemas/Verification"},{"type":"null"}],"description":"What was checked by running, and what by reading."},"progress":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Progress","description":"A word about how the student is doing overall."}},"additionalProperties":false,"type":"object","required":["schema_version","language"],"title":"ReviewStructureV1","description":"A whole review, in the order it will be read.\n\nVersion comes from :class:`VersionedReview` and is the only one: this class\nadds no version key of its own, so there is never a question of which of two\nversions a reader should believe.\n\nEvery section is ``None`` or empty when it has nothing to say, and the\nassembler leaves silent sections out entirely rather than printing an empty\nheading. What the model refuses is a review with nothing in any of them."},"RootNodeCreateRequest":{"properties":{"title":{"type":"string","maxLength":500,"minLength":1,"title":"Title","description":"Node title displayed in the material tree.","examples":["Python for Beginners"]},"description":{"anyOf":[{"type":"string","maxLength":5000},{"type":"null"}],"title":"Description","description":"Optional detailed description of the node's purpose.","examples":["Foundational Python course."]},"default_language":{"type":"string","title":"Default Language","description":"Required language of instruction. Accepts ISO 639-1 / 639-3 / English name; stored as canonical ISO 639-3. Must be in the whitelist served by ``GET /api/v1/config/languages``.","examples":["uk","ukr","Ukrainian"]}},"type":"object","required":["title","default_language"],"title":"RootNodeCreateRequest","description":"Request body for creating a root node (= course).\n\n``default_language`` is **required** for root nodes (Task 2.4.13:\ncourse language is the entry-gate for Pass 2a language pinning).\nAccepts any standard language description — 639-1 (``\"uk\"``), 639-3\n(``\"ukr\"``), or English name (``\"Ukrainian\"``) — and stores it as\ncanonical 639-3 after validation against ``config/languages.yaml``.\n\nExample::\n\n    {\n        \"title\": \"Python for Beginners\",\n        \"description\": \"Foundational Python course\",\n        \"default_language\": \"uk\"\n    }"},"SetStudentPasswordRequest":{"properties":{"password":{"type":"string","minLength":1,"title":"Password","description":"New portal password (plaintext)."}},"type":"object","required":["password"],"title":"SetStudentPasswordRequest","description":"Request body for POST /students/{id}/password (DD-6-J, scope PREP).\n\nAuthor-side password reset — the fallback path when a student has no\nconfirmed recovery email. Schema floor only (``min_length=1``); the real\nminimum-length policy is enforced at hash time (argon2id, mapping a weak\npassword to 422), exactly as provisioning does."},"SourceType":{"type":"string","enum":["video","presentation","text","web","audio","code","test_object"],"title":"SourceType","description":"Types of source materials. Mirrors ORM source_type_enum."},"StorageFileResponse":{"properties":{"key":{"type":"string","title":"Key","description":"Full S3 object key."},"size_bytes":{"type":"integer","title":"Size Bytes","description":"File size in bytes."},"last_modified":{"type":"string","format":"date-time","title":"Last Modified","description":"Last modification timestamp."}},"type":"object","required":["key","size_bytes","last_modified"],"title":"StorageFileResponse","description":"Single file in tenant's S3 storage."},"StorageUsageResponse":{"properties":{"total_bytes":{"type":"integer","title":"Total Bytes","description":"Total storage used in bytes."},"file_count":{"type":"integer","title":"File Count","description":"Number of files in storage."}},"type":"object","required":["total_bytes","file_count"],"title":"StorageUsageResponse","description":"Tenant storage usage summary."},"StudentEnrollmentItem":{"properties":{"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"Enrolled root CourseNode id."},"enrolled_at":{"type":"string","format":"date-time","title":"Enrolled At","description":"When the grant was created."}},"type":"object","required":["course_node_id","enrolled_at"],"title":"StudentEnrollmentItem","description":"One enrollment of a student (GET /students/{id}/enrollments, T5).\n\nOnly ``course_node_id`` + ``enrolled_at`` — the title is resolved\nclient-side from the roots the admin UI already holds. Enrollments to\na since-soft-deleted course are preserved here (unlike the portal\nlisting) so the admin can see and unbind them."},"StudentEnrollmentsResponse":{"properties":{"student_id":{"type":"string","format":"uuid","title":"Student Id","description":"The student these grants belong to."},"items":{"items":{"$ref":"#/components/schemas/StudentEnrollmentItem"},"type":"array","title":"Items","description":"Enrollments, oldest-first (mirrors bind order)."}},"type":"object","required":["student_id","items"],"title":"StudentEnrollmentsResponse","description":"A student's enrollments (GET /students/{id}/enrollments, T5)."},"StudentRosterItem":{"properties":{"student_id":{"type":"string","format":"uuid","title":"Student Id","description":"Student primary key."},"external_id":{"type":"string","title":"External Id","description":"Caller-system student identifier."},"login":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Login","description":"Portal login, or null if no credential provisioned."},"display_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Display Name","description":"Portal display name (nullable)."},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active","description":"Credential state: null = no credential, true = active, false = access revoked. Mirrors the revoke/restore toggle."},"enrollment_count":{"type":"integer","title":"Enrollment Count","description":"Number of course enrollments (correlated count)."}},"type":"object","required":["student_id","external_id","enrollment_count"],"title":"StudentRosterItem","description":"One row of the tenant-admin student roster (GET /students, T5).\n\nProjects ``Student`` LEFT JOIN ``StudentCredential``: ``login`` and\n``is_active`` are null for integration-mode students that never got a\nportal credential — they are still listed so the admin can attach one\nvia the ``existing`` provisioning mode. ``is_active`` mirrors the\nrevoke state (deferred hard-delete, DD-6-A): null = no credential,\ntrue = active, false = access revoked."},"StudentRosterResponse":{"properties":{"tenant_id":{"type":"string","format":"uuid","title":"Tenant Id","description":"The tenant these students belong to (DD-6-M). Lets the author UI build the shared portal link ${PORTAL_ORIGIN}/${tenant_id}/login without exposing the UUID elsewhere; present even at 0 courses."},"items":{"items":{"$ref":"#/components/schemas/StudentRosterItem"},"type":"array","title":"Items","description":"Students for the current page (newest-first)."},"total":{"type":"integer","title":"Total","description":"Total non-deleted students for the tenant."},"limit":{"type":"integer","title":"Limit","description":"Maximum items per page (as requested)."},"offset":{"type":"integer","title":"Offset","description":"Number of items skipped (as requested)."}},"type":"object","required":["tenant_id","items","total","limit","offset"],"title":"StudentRosterResponse","description":"Paginated tenant-admin student roster (GET /students, T5)."},"SubmissionPolicyEntry":{"properties":{"max_bytes":{"type":"integer","title":"Max Bytes","description":"Upload cap in bytes, exactly as the server checks it (no rounding to MB — the door compares bytes)."},"accept":{"items":{"type":"string"},"type":"array","title":"Accept","description":"Allowed extensions, dot-prefixed and sorted — the form of the HTML ``accept`` attribute."},"archive_only":{"type":"boolean","title":"Archive Only","description":"Whether a submission must be an archive of the whole project; a loose file is refused with ``ARCHIVE_ONLY``."}},"type":"object","required":["max_bytes","accept","archive_only"],"title":"SubmissionPolicyEntry","description":"What the submission door accepts for one kind of assignment.\n\nThree facts, one per gate the server actually runs, in the order a\nsubmission meets them: the extension allowlist\n(``validate_homework_file``), the size cap the route resolves from the\ntask kind, and — for a project — the archive-only rule that\n``project_preflight`` enforces afterwards.\n\n``accept`` is the same list for every kind because the extension check\nIS task-agnostic on the server; a project narrows it further through\n``archive_only`` rather than through a shorter list. Two fields for two\ngates keeps the client's picture identical to the server's, instead of\npre-baking one gate into the other and hiding which door refused."},"SubmissionPolicyResponse":{"properties":{"policies":{"additionalProperties":{"$ref":"#/components/schemas/SubmissionPolicyEntry"},"propertyNames":{"$ref":"#/components/schemas/AssignmentType"},"type":"object","title":"Policies","description":"Submission policy by assignment kind; every value of ``AssignmentType`` is present."}},"type":"object","required":["policies"],"title":"SubmissionPolicyResponse","description":"Response shape for ``GET /api/v1/portal/submission-policy``.\n\nOne entry per value of :class:`AssignmentType` — all four, not the two\nthe server branches on. The server's own rule is binary (project vs\neverything else), but the portal holds the raw ``task_type`` of the\ndocument it is rendering, so a table keyed on the two it happens to\nbranch on would leave a ``test`` or ``short_task`` assignment without a\nrow and push a \"not project → task\" rule back onto the client. That\nrule is a copy of server logic on the far side of the repository\nboundary, which is the whole of ``DD-SP-V``.\n\nThe three non-project kinds therefore carry identical numbers on\npurpose: the duplication is in the wire format, where it costs nothing,\ninstead of in a client that has to know why."},"TaskTouchCountersEntry":{"properties":{"helped":{"type":"integer","minimum":0.0,"title":"Helped","description":"Answers 'the review helped'."},"not_helped":{"type":"integer","minimum":0.0,"title":"Not Helped","description":"Answers 'it did not'."},"authored_document_id":{"type":"string","format":"uuid","title":"Authored Document Id"},"task_label":{"type":"string","title":"Task Label","description":"The same label the cost breakdown uses: ``filename | {source_type} #{order}``."},"is_deleted":{"type":"boolean","title":"Is Deleted"}},"type":"object","required":["helped","not_helped","authored_document_id","task_label","is_deleted"],"title":"TaskTouchCountersEntry","description":"One task in the by-task breakdown of a course.\n\n``is_deleted`` marks a soft-deleted TASK: shown, not filtered, the same way\nthe cost breakdown shows it — the answers students gave about its reviews\nwere still given. What IS filtered out of the numbers is a soft-deleted\nsubmission and a soft-deleted student."},"TaskTouchCountersResponse":{"properties":{"helped":{"type":"integer","minimum":0.0,"title":"Helped","description":"Answers 'the review helped'."},"not_helped":{"type":"integer","minimum":0.0,"title":"Not Helped","description":"Answers 'it did not'."},"authored_document_id":{"type":"string","format":"uuid","title":"Authored Document Id"}},"type":"object","required":["helped","not_helped","authored_document_id"],"title":"TaskTouchCountersResponse","description":"Response of ``GET /api/v1/feedback/counters/task/{authored_document_id}``.\n\nThe task's totals over every student who answered. A foreign or unknown\ntask answers zeros, for the same reason the course level answers empty."},"TenantWebhookResponse":{"properties":{"webhook_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Url","description":"Current default webhook URL for the tenant. None if cleared."}},"type":"object","required":["webhook_url"],"title":"TenantWebhookResponse","description":"Response for PATCH /tenant/webhook."},"TestOption":{"properties":{"label":{"type":"string","minLength":1,"title":"Label","description":"The label, as written in the test."},"text":{"type":"string","title":"Text","description":"The option's text; empty when the label is all it says."}},"additionalProperties":false,"type":"object","required":["label","text"],"title":"TestOption","description":"One option of a test question, as the author wrote it: label and text.\n\nThe label is the author's own — the letter as the test prints it, capital\nor small — not the canonical form the answers are checked in: the student\nrecognises the option they saw."},"TestQuestionResult":{"properties":{"number":{"type":"string","minLength":1,"title":"Number","description":"The question's number, as asked."},"correct":{"type":"boolean","title":"Correct","description":"Whether the student's answer is right."},"correct_answer":{"anyOf":[{"items":{"$ref":"#/components/schemas/TestOption"},"type":"array"},{"type":"null"}],"title":"Correct Answer","description":"The correct option or options, for a wrong answer only."},"explanation":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Explanation","description":"Why the correct answer is correct, for a wrong answer only."}},"additionalProperties":false,"type":"object","required":["number","correct"],"title":"TestQuestionResult","description":"One question of a test: right or wrong, and for a wrong one, what is right.\n\nA right answer gets its verdict and nothing else. A wrong one gets the\ncorrect option, label and text, and the explanation — or ``None`` when\nthere is none to show: none written yet, none in any language the student\ncan be given, or one the model doubted (``03-BINDING.md`` 4.4, points 5, 6\nand 8).\n\n>>> TestQuestionResult(number=\"1\", correct=True).correct_answer is None\nTrue"},"TestSection":{"properties":{"score":{"type":"integer","maximum":100.0,"minimum":0.0,"title":"Score","description":"Whole percent of questions right."},"questions":{"items":{"$ref":"#/components/schemas/TestQuestionResult"},"type":"array","minItems":1,"title":"Questions","description":"Every question of the test, in order."},"explanations_in_course_language":{"type":"boolean","title":"Explanations In Course Language","description":"True when an explanation shown here is in the course language rather than the review's, which the review then says.","default":false},"retry_offer":{"type":"boolean","title":"Retry Offer","description":"True when the test is not passed: the review ends with an offer to take it again.","default":false}},"additionalProperties":false,"type":"object","required":["score","questions"],"title":"TestSection","description":"A test's result: the score, and every question in the order it was asked.\n\nThe score is the share of questions answered right, in whole percent,\nrounded down (``homework/test_scoring.py``). It is printed by the assembler\n— under the verdict when there is one, as the first line of this section\nwhen there is not — which is why the verdict of a test needs no ``why``."},"TestStructureOption":{"properties":{"label":{"type":"string","title":"Label","description":"The option's label as the test prints it."},"text":{"type":"string","title":"Text","description":"The option's text."}},"type":"object","required":["label","text"],"title":"TestStructureOption","description":"One option of a question, as the test prints it."},"TestStructureQuestion":{"properties":{"number":{"type":"string","title":"Number","description":"The question's number as the test prints it."},"text":{"type":"string","title":"Text","description":"The question's text."},"options":{"items":{"$ref":"#/components/schemas/TestStructureOption"},"type":"array","title":"Options","description":"The options, in the order the test lists them."}},"type":"object","required":["number","text","options"],"title":"TestStructureQuestion","description":"One question of a test, with its options in the order the test lists them."},"TestStructureResponse":{"properties":{"version":{"type":"string","title":"Version","description":"The test's current version. Send it back as ``test_version`` with the answers."},"accepting_answers":{"type":"boolean","title":"Accepting Answers","description":"True when answers are taken now: tests are answered on the new path. A published version always carries its key, so nothing else holds answers back. While false, answers are refused (``TEST_FORM_UNAVAILABLE``)."},"questions":{"items":{"$ref":"#/components/schemas/TestStructureQuestion"},"type":"array","title":"Questions","description":"Every question of the test, in the order it asks them."}},"type":"object","required":["version","accepting_answers","questions"],"title":"TestStructureResponse","description":"A test as it is answered: its version, whether answers are taken, questions.\n\nReturned by ``GET /portal/tasks/{id}/test`` and ``GET /homework/tasks/{id}/test``.\nNothing of the key crosses here — not the right options, not how many of\nthem a question has, not the explanations, the doubts or the pass mark\n(task 07, invariant 5 and decision 22)."},"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"},"Verdict":{"properties":{"passed":{"type":"boolean","title":"Passed","description":"Whether the work is accepted."},"why":{"anyOf":[{"type":"string","minLength":1},{"type":"null"}],"title":"Why","description":"The reason, in the student's language. Left out only by a test's verdict, whose reason is the score."}},"additionalProperties":false,"type":"object","required":["passed"],"title":"Verdict","description":"Passed or not, and why.\n\n``why`` is given on both outcomes: a pass with no reason teaches as little\nas a failure with none. A test's verdict is the one exception, because its\nreason is its score, and the score is written by the assembler from the\ntest section (task 07, decision 20). So the rule is checked where both are\nvisible — on :class:`ReviewStructureV1` — not here."},"Verification":{"properties":{"by_run":{"items":{"type":"string"},"type":"array","title":"By Run","description":"What was established by running it."},"by_reading":{"items":{"type":"string"},"type":"array","title":"By Reading","description":"What was established by reading it."}},"additionalProperties":false,"type":"object","title":"Verification","description":"What was checked by running the work, and what by reading it.\n\nTwo halves of one section, kept apart because the difference is what the\nstudent is being told: \"I ran it and it failed\" is a fact, \"I read it and it\nlooks wrong\" is a judgement, and conflating them costs trust."},"WeightCategory":{"type":"string","enum":["must","should","may"],"title":"WeightCategory","description":"How much a criterion matters (task 08, ``TASK.md`` 3.1).\n\nThe category is what the author edits; the number it counts for is the\ncode's (``homework.criteria_form.WEIGHT_NUMBERS``), so stored lists hold no\nnumbers and a new scale re-weighs every list at once. The values are the\nEnglish labels the model is given (task 08, decision 18) and stay so in\nstorage."},"WrittenTestCheck":{"properties":{"state":{"$ref":"#/components/schemas/DraftCheckState","description":"not_checked — nothing asked for the draft as it stands: never checked, changed since, or unfinished; in_progress — being written; ready — the explanations and doubts are here; failed — the writing gave up, and a check asks for it again."},"explanations":{"additionalProperties":{"type":"string"},"type":"object","title":"Explanations","description":"The model's explanation of each question, by its number; empty until ready."},"doubts":{"additionalProperties":{"type":"boolean"},"type":"object","title":"Doubts","description":"Questions on which the model doubts the author's answer, by number. A student is not shown the model's explanation of a doubted question; the author's own explanation of it they are. Empty until ready."}},"type":"object","required":["state","explanations","doubts"],"title":"WrittenTestCheck","description":"What the check of a draft found, and how far it got (task 07c).\n\nReturned by ``POST /tests/{id}/check`` and carried by every reading of the\ndraft, for the draft as it stands. The explanations and doubts are the\nmodel's alone; the author's own explanations are the draft's."},"WrittenTestDraft":{"properties":{"pass_threshold":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Pass Threshold","description":"The pass mark, 1 to 100, if set."},"questions":{"items":{"$ref":"#/components/schemas/WrittenTestQuestion"},"type":"array","title":"Questions","description":"Every question, in order."}},"type":"object","required":["pass_threshold","questions"],"title":"WrittenTestDraft","description":"The draft of a test written in the system, marks and all."},"WrittenTestIncompletePlace":{"properties":{"code":{"$ref":"#/components/schemas/IncompleteCode","description":"What is unfinished: no questions, an empty text, fewer than two options, or no option marked right."},"question":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Question","description":"The question's place, from 1; null for a test with no questions."},"option":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Option","description":"The option's place, from 1, for an option's own empty text; null otherwise."}},"type":"object","required":["code","question","option"],"title":"WrittenTestIncompletePlace","description":"A place where a draft is not finished yet (task 07c).\n\nA draft is saved unfinished; a check or a publication of one is refused,\nand the draft's reading lists every such place — the same entries as the\nrefusal's ``incomplete``."},"WrittenTestOption":{"properties":{"label":{"type":"string","title":"Label","description":"The letter a publication gives this option now: its place in the question, in the course's alphabet."},"text":{"type":"string","title":"Text","description":"The option's text."},"correct":{"type":"boolean","title":"Correct","description":"Whether the author marked it right."}},"type":"object","required":["label","text","correct"],"title":"WrittenTestOption","description":"An option of a draft as its author sees it, with the letter it would get."},"WrittenTestPublicationResponse":{"properties":{"created":{"type":"boolean","title":"Created","description":"True for a new version (201); false when the draft equals the version in force (200)."},"published":{"$ref":"#/components/schemas/WrittenTestVersion","description":"The version in force after the publication."}},"type":"object","required":["created","published"],"title":"WrittenTestPublicationResponse","description":"What ``POST /tests/{id}/publish`` did: a new version, or the same one."},"WrittenTestQuestion":{"properties":{"number":{"type":"string","title":"Number","description":"Its place in the test, from 1."},"text":{"type":"string","title":"Text","description":"The question's text."},"options":{"items":{"$ref":"#/components/schemas/WrittenTestOption"},"type":"array","title":"Options","description":"Its options, in order."},"explanation":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Explanation","description":"The author's own explanation, when written."}},"type":"object","required":["number","text","options","explanation"],"title":"WrittenTestQuestion","description":"A question of a draft as its author sees it, numbered as it would be."},"WrittenTestResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"The test's document id."},"course_node_id":{"type":"string","format":"uuid","title":"Course Node Id","description":"The node the test is in."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"The name both trees show."},"language":{"type":"string","title":"Language","description":"The course language the letters are in (ISO 639-3)."},"draft":{"$ref":"#/components/schemas/WrittenTestDraft","description":"The draft, as a publication would read it now."},"published":{"anyOf":[{"$ref":"#/components/schemas/WrittenTestVersion"},{"type":"null"}],"description":"The version in force, or null before the first publication."},"course_root_id":{"type":"string","format":"uuid","title":"Course Root Id","description":"The root of the course the test is in."},"unpublished_changes":{"type":"boolean","title":"Unpublished Changes","description":"Whether the draft differs from the version in force — in a question, an option, a mark, the pass mark, an explanation of the author's own or the course language; true before the first publication. A new title is no change: no version carries it."},"incomplete":{"items":{"$ref":"#/components/schemas/WrittenTestIncompletePlace"},"type":"array","title":"Incomplete","description":"Every place the draft is not finished at, in reading order; empty for a finished draft. A check or a publication of an unfinished draft is refused with the same list."},"check":{"$ref":"#/components/schemas/WrittenTestCheck","description":"What the check of the draft as it stands found."}},"type":"object","required":["id","course_node_id","title","language","draft","published","course_root_id","unpublished_changes","incomplete","check"],"title":"WrittenTestResponse","description":"A test written in the system as its author reads it (task 07b).\n\nReturned by ``POST /nodes/{node_id}/tests`` and by the ``/tests/{id}/draft``\nroutes. The draft carries the marks the author set and the letters a\npublication would give its options now; ``published`` is the version in\nforce, or ``null`` before the first publication. From task 07c:\n``incomplete`` is what is left to finish before the draft can be checked\nor published, ``unpublished_changes`` whether a publication now would give\na new version, and ``check`` what the draft's check found — read, never\nasked for."},"WrittenTestVersion":{"properties":{"number":{"type":"integer","title":"Number","description":"The version's number, from 1."},"version":{"type":"string","title":"Version","description":"Its visible digest — the ``version`` the structure routes show a student."},"published_at":{"type":"string","format":"date-time","title":"Published At","description":"When it was published."}},"type":"object","required":["number","version","published_at"],"title":"WrittenTestVersion","description":"A published version of a test written in the system."}},"securitySchemes":{"APIKeyHeader":{"type":"apiKey","in":"header","name":"X-API-Key"},"HTTPBearer":{"type":"http","scheme":"bearer"}}}}