{"openapi":"3.1.0","info":{"title":"Botree LRS","version":"0.43.1"},"servers":[{"url":"https://lrs.botreeinc.com"}],"security":[{"bearerAuth":[]}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"OIDC access token issued by one of this deployment's registered sign-in providers. Their issuers are listed in the protected resource metadata at /.well-known/oauth-protected-resource (RFC 9728), which every 401 challenge links to."},"cronSecret":{"type":"http","scheme":"bearer","description":"The deployment's `CRON_SECRET` environment variable, as Vercel Cron presents it. Only the notification schedule tick and the AI search index tick accept it."}},"schemas":{"TokenInfo":{"type":"object","properties":{"active":{"type":"boolean","enum":[true]},"subject":{"type":"string","example":"usr_123"},"client_id":{"type":"string","example":"botree-lrs"},"issuer":{"type":"string","example":"https://auth.botreeinc.com"},"audience":{"type":"array","items":{"type":"string"}},"scopes":{"type":"array","items":{"type":"string"},"example":["lrs:read"]},"issued_at":{"type":"integer","description":"Seconds since the epoch."},"expires_at":{"type":"integer","description":"Seconds since the epoch."},"validated_by":{"type":"string","enum":["jwks","introspection"],"description":"Whether the token was validated locally against the JWKS or by introspection."}},"required":["active","audience","scopes","validated_by"]},"ErrorResponse":{"type":"object","properties":{"error":{"type":"string","example":"invalid_token"},"error_description":{"type":"string","example":"The access token has expired"}},"required":["error","error_description"]},"ProtectedResourceMetadata":{"type":"object","properties":{"resource":{"type":"string","format":"uri","description":"The resource identifier: this deployment's public origin.","example":"https://lrs.example.com"},"authorization_servers":{"type":"array","items":{"type":"string","format":"uri"},"description":"Issuer identifiers of the enabled sign-in providers; an access token from any of them is accepted.","example":["https://auth.example.com"]},"bearer_methods_supported":{"type":"array","items":{"type":"string"},"description":"How the token is presented; always the Authorization header.","example":["header"]},"resource_name":{"type":"string","description":"The deployment's name as configured under branding, or the product name when none is set.","example":"Botree Learning Record Storage"},"resource_documentation":{"type":"string","format":"uri","description":"The API reference.","example":"https://lrs.example.com/docs"}},"required":["resource","authorization_servers","bearer_methods_supported","resource_name","resource_documentation"]},"Organization":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","slug","name","description","created_at"]},"CreateOrganizationRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"acme-corporation","description":"Unique across all organizations. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"ACME Corporation"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."}},"required":["slug","name"]},"UpdateOrganizationRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","description":"Unique across all organizations. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success."},"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null or empty to clear the description."}}},"Department":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"parent_department_id":{"type":["string","null"],"format":"uuid"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","organization_id","slug","name","description","parent_department_id","created_at"]},"CreateDepartmentRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"quality-assurance","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Quality Assurance"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."},"parent_department_id":{"type":"string","format":"uuid","description":"Nests the new department under one in the same organization."}},"required":["slug","name"]},"UpdateDepartmentRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success."},"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null or empty to clear the description."},"parent_department_id":{"type":["string","null"],"format":"uuid","description":"Moves the department under another in the same organization — never itself or anything nested beneath it. Send null to make it top level."}}},"DepartmentRole":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"department_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"permissions":{"type":"array","items":{"type":"string"}},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","department_id","slug","name","description","permissions","created_at"]},"CreateDepartmentRoleRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"reviewer","description":"Unique within the department. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Reviewer"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."},"permissions":{"type":"array","items":{"type":"string","enum":["records:read","records:write","records:delete","subjects:read","subjects:write","subjects:delete","certifications:read","certifications:write","certifications:delete","quizzes:read","quizzes:write","quizzes:grade","quizzes:delete","external-lms:read","external-lms:write","external-lms:delete","scorm:read","scorm:write","scorm:delete","job-aids:read","job-aids:write","job-aids:delete","events:read","events:write","events:delete","qr-codes:read","qr-codes:write","qr-codes:delete","forums:read","forums:write","forums:moderate","forums:delete","directory:write","directory:delete"]},"maxItems":34,"default":[],"description":"The permissions the role grants, from the set the code understands. Duplicates are collapsed; an empty array grants nothing.","example":["records:read","records:write"]}},"required":["slug","name"]},"UpdateDepartmentRoleRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","description":"Unique within the department. Slugs are lowercase letters and digits in words separated by single hyphens, like customer-success."},"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null or empty to clear the description."},"permissions":{"type":"array","items":{"type":"string","enum":["records:read","records:write","records:delete","subjects:read","subjects:write","subjects:delete","certifications:read","certifications:write","certifications:delete","quizzes:read","quizzes:write","quizzes:grade","quizzes:delete","external-lms:read","external-lms:write","external-lms:delete","scorm:read","scorm:write","scorm:delete","job-aids:read","job-aids:write","job-aids:delete","events:read","events:write","events:delete","qr-codes:read","qr-codes:write","qr-codes:delete","forums:read","forums:write","forums:moderate","forums:delete","directory:write","directory:delete"]},"maxItems":34,"description":"Replaces the whole permission set, from the set the code understands. Duplicates are collapsed; an empty array grants nothing.","example":["records:read","records:write"]}}},"DeletionImpact":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","description":"The row's display name; the dialog asks the caller to type it back."},"consequences":{"type":"array","items":{"$ref":"#/components/schemas/DeletionConsequence"},"description":"Every kind of dependent data with a non-zero count, in the order it matters most. Empty when nothing but the row itself goes."}},"required":["id","name","consequences"]},"DeletionConsequence":{"type":"object","properties":{"key":{"type":"string","description":"Stable identifier of the consequence, e.g. `awards` or `attendance_records`.","example":"awards"},"count":{"type":"integer","minimum":0,"description":"How many rows of this kind the deletion removes or alters.","example":12},"description":{"type":"string","description":"What happens to those rows, phrased for the person confirming.","example":"12 certification awards held by 7 people will be deleted for good."}},"required":["key","count","description"]},"DirectoryImportSummary":{"type":"object","properties":{"total_rows":{"type":"integer","description":"Data rows in the file, not counting the header.","example":25},"added_count":{"type":"integer","description":"Members added to the directory by this import.","example":23},"skipped_count":{"type":"integer","description":"Rows skipped because their subject was already in the directory.","example":2},"skipped_subjects":{"type":"array","items":{"type":"string"},"description":"The file's subjects whose accounts were already in the directory, in file order; their existing accounts and profiles are untouched.","example":["auth0|507f1f77bcf86cd799439011"]},"email_linked_count":{"type":"integer","description":"Email-only rows for which an account with a pending email link was created; always 0 unless allow_email_only was set.","example":0},"skipped_emails":{"type":"array","items":{"type":"string"},"description":"The file's email-only addresses whose accounts were already in the directory, in file order.","example":[]}},"required":["total_rows","added_count","skipped_count","skipped_subjects","email_linked_count","skipped_emails"]},"DirectoryImportForm":{"type":"object","properties":{"provider_id":{"type":"string","minLength":1,"description":"Sign-in provider UUID id or slug; every row's user_id is the OIDC `sub` claim the member will present at this provider.","example":"acme-sso"},"allow_email_only":{"type":"string","enum":["true","false"],"description":"Set true to accept rows with a blank (or absent) user_id and an email: each resolves to the account a pending email link or a single live profile email already names, or else creates an account with a pending email link at the provider, so that person's first sign-in there lands in it. Default false."},"file":{"type":"string","format":"binary","description":"CSV file, at most 1 MB: a header row naming a `user_id` (the OIDC `sub` claim at the named provider; `user_subject`, `subject` and `sub` are accepted too), `name` (or `display_name`) and `email` column in any order, then one member per row. Header matching is case-insensitive and extra columns are ignored. A member's name and email cells may be left blank; they seed a newly created account's profile. With `allow_email_only`, the user_id column may be blank or missing and rows are matched by email instead."}},"required":["provider_id","file"]},"DirectoryUser":{"type":"object","properties":{"organization_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"display_name":{"type":["string","null"],"description":"The member's canonical profile, refreshed from their sign-ins."},"email":{"type":["string","null"]},"kind":{"type":"string","enum":["human","machine"],"description":"`machine` marks accounts created from client-credentials tokens."},"active_role_count":{"type":"integer","description":"Roles the member currently holds here; expired grants are not counted.","example":2},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["organization_id","user_id","display_name","email","kind","active_role_count","created_at"]},"AddDirectoryUserRequest":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"The member's account id (`users.id`). Mutually exclusive with provider_id/subject."},"provider_id":{"type":"string","minLength":1,"description":"Sign-in provider UUID id or slug.","example":"acme-sso"},"subject":{"type":"string","minLength":1,"maxLength":255,"description":"OIDC `sub` claim the member will present at that provider.","example":"auth0|507f1f77bcf86cd799439011"},"display_name":{"type":"string","minLength":1,"maxLength":300,"description":"Starting profile for a newly created account; ignored for an existing one."},"email":{"type":"string","minLength":1,"maxLength":300}}},"DirectoryUserWithRoles":{"type":"object","properties":{"organization_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"display_name":{"type":["string","null"],"description":"The member's canonical profile, refreshed from their sign-ins."},"email":{"type":["string","null"]},"kind":{"type":"string","enum":["human","machine"],"description":"`machine` marks accounts created from client-credentials tokens."},"active_role_count":{"type":"integer","description":"Roles the member currently holds here; expired grants are not counted.","example":2},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"roles":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryUserRole"},"description":"Every grant the member holds here, expired ones included."}},"required":["organization_id","user_id","display_name","email","kind","active_role_count","created_at","roles"]},"DirectoryUserRole":{"type":"object","properties":{"department_id":{"type":"string","format":"uuid"},"department_slug":{"type":"string"},"department_name":{"type":"string"},"role_id":{"type":"string","format":"uuid"},"role_slug":{"type":"string"},"role_name":{"type":"string"},"role_description":{"type":["string","null"]},"permissions":{"type":"array","items":{"type":"string"},"example":["records:read","records:write"]},"granted_by":{"type":["string","null"],"description":"`users.id` of whoever granted the role; null when unrecorded."},"granted_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"expires_at":{"type":["string","null"],"format":"date-time","description":"When the grant lapses; null for an open-ended one."}},"required":["department_id","department_slug","department_name","role_id","role_slug","role_name","role_description","permissions","granted_by","granted_at","expires_at"]},"AssignDirectoryUserRoleRequest":{"type":"object","properties":{"department_id":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within the organization.","example":"quality-assurance"},"role_id":{"type":"string","minLength":1,"description":"Role UUID id or slug, resolved within the department.","example":"reviewer"},"expires_at":{"type":"string","format":"date-time","description":"When the grant lapses; omit for an open-ended one. Must be in the future."}},"required":["department_id","role_id"]},"UpdateDirectoryUserRoleRequest":{"type":"object","properties":{"expires_at":{"type":["string","null"],"format":"date-time","description":"New expiry of the grant; null makes it open-ended."}},"required":["expires_at"]},"RoleManagers":{"type":"object","properties":{"managers":{"type":"array","items":{"$ref":"#/components/schemas/RoleManagerEdge"},"description":"The roles managing this role, each edge's far side resolved."},"subordinates":{"type":"array","items":{"$ref":"#/components/schemas/RoleManagerEdge"},"description":"The roles this role manages, each edge's far side resolved."}},"required":["managers","subordinates"]},"RoleManagerEdge":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"role":{"$ref":"#/components/schemas/RoleManagerRef"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","organization_id","role","created_at"]},"RoleManagerRef":{"type":"object","properties":{"role_id":{"type":"string","format":"uuid"},"role_slug":{"type":"string"},"role_name":{"type":"string"},"department_id":{"type":"string","format":"uuid"},"department_slug":{"type":"string"},"department_name":{"type":"string"}},"required":["role_id","role_slug","role_name","department_id","department_slug","department_name"]},"CreateRoleManagerRequest":{"type":"object","properties":{"manager_role_id":{"type":"string","format":"uuid","description":"The role to make a manager of this one. Any role of the same organization except this role itself, or one that would close a cycle."}},"required":["manager_role_id"]},"OrganizationSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired and should not gain new children."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","slug","name","description","archived_at","created_at"]},"OrganizationWithDepartments":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired and should not gain new children."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"departments":{"type":"array","items":{"$ref":"#/components/schemas/DepartmentSummary"},"description":"Every department in the organization, nested ones included, ordered by name."}},"required":["id","slug","name","description","archived_at","created_at","departments"]},"DepartmentSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"parent_department_id":{"type":["string","null"],"format":"uuid"},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired and should not gain new children."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","organization_id","slug","name","description","parent_department_id","archived_at","created_at"]},"DepartmentRoleSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"department_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"permissions":{"type":"array","items":{"type":"string"},"example":["records:read","records:write"]},"member_count":{"type":"integer","description":"Users currently holding the role; expired grants are not counted.","example":3},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","department_id","slug","name","description","permissions","member_count","created_at"]},"SignInProviderList":{"type":"object","properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/SignInProvider"}}},"required":["providers"]},"SignInProvider":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","example":"acme-sso"},"display_name":{"type":"string","description":"Label shown on the sign-in picker.","example":"Acme SSO"},"issuer_url":{"type":"string","example":"https://auth.acme.com"},"client_id":{"type":"string"},"enabled":{"type":"boolean","description":"Disabled providers keep their identities but accept no sign-ins or tokens."},"client_auth_method":{"type":"string","enum":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"]},"client_secret_env":{"type":["string","null"],"description":"NAME of the env var holding the client secret; the secret itself never leaves the environment.","example":"OIDC_ACME_CLIENT_SECRET"},"client_private_key_env":{"type":["string","null"],"description":"NAME of the env var holding the PKCS#8 private key, for private_key_jwt."},"client_assertion_algorithm":{"type":["string","null"]},"client_key_id":{"type":["string","null"]},"audience":{"type":["string","null"],"description":"Expected `aud` of incoming access tokens; null skips the audience check."},"request_resource":{"type":"boolean","description":"Send `audience` as the RFC 8707 `resource` parameter on the sign-in and refresh grants, so the tokens the browser holds carry that audience."},"introspection_client_id":{"type":["string","null"],"description":"Identity tokens are introspected as, when it differs from the login client — an API server validating the tokens minted for it. Null introspects as the login client."},"introspection_auth_method":{"type":["string","null"],"enum":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt",null]},"introspection_client_secret_env":{"type":["string","null"],"description":"NAME of the env var holding the introspection identity's client secret."},"introspection_private_key_env":{"type":["string","null"],"description":"NAME of the env var holding the introspection identity's PKCS#8 private key, for private_key_jwt."},"introspection_client_assertion_algorithm":{"type":["string","null"]},"introspection_client_key_id":{"type":["string","null"]},"login_scope":{"type":"string","example":"openid profile email"},"access_token_algorithms":{"type":"array","items":{"type":"string"},"description":"Accepted JWS algorithms for incoming access tokens; empty means the defaults.","example":["RS256"]},"email_verification_strictness":{"type":"string","enum":["lenient","reject_unverified","require_verified"],"description":"How the provider's `email_verified` claim gates claiming a pending email link at sign-in: `lenient` links regardless, `reject_unverified` links unless the claim is explicitly false, `require_verified` links only when it is explicitly true.","example":"reject_unverified"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","slug","display_name","issuer_url","client_id","enabled","client_auth_method","client_secret_env","client_private_key_env","client_assertion_algorithm","client_key_id","audience","request_resource","introspection_client_id","introspection_auth_method","introspection_client_secret_env","introspection_private_key_env","introspection_client_assertion_algorithm","introspection_client_key_id","login_scope","access_token_algorithms","email_verification_strictness","created_at","updated_at"]},"CreateSignInProviderRequest":{"type":"object","properties":{"slug":{"type":"string","maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"display_name":{"type":"string","minLength":1,"maxLength":200},"issuer_url":{"type":"string","maxLength":500,"format":"uri"},"client_id":{"type":"string","minLength":1,"maxLength":500},"client_auth_method":{"type":"string","enum":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"]},"client_secret_env":{"type":"string","maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$"},"client_private_key_env":{"type":"string","maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$"},"client_assertion_algorithm":{"type":"string","minLength":1,"maxLength":20},"client_key_id":{"type":"string","minLength":1,"maxLength":200},"audience":{"type":"string","minLength":1,"maxLength":500},"request_resource":{"type":"boolean","description":"Defaults to false. Requires an audience."},"introspection_client_id":{"type":["string","null"],"minLength":1,"maxLength":500,"description":"Introspect tokens as this client instead of the login client (an API server id, on a SchemaVaults auth server). The introspection_* fields are replaced together: send this with the others; null (on update) drops the identity."},"introspection_auth_method":{"type":["string","null"],"enum":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt",null]},"introspection_client_secret_env":{"type":["string","null"],"maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$"},"introspection_private_key_env":{"type":["string","null"],"maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$"},"introspection_client_assertion_algorithm":{"type":["string","null"],"minLength":1,"maxLength":20},"introspection_client_key_id":{"type":["string","null"],"minLength":1,"maxLength":200},"login_scope":{"type":"string","minLength":1,"maxLength":500,"description":"Defaults to \"openid profile email\"."},"access_token_algorithms":{"type":"array","items":{"type":"string","minLength":1,"maxLength":20},"maxItems":20,"description":"Omit to accept the default algorithm set."},"email_verification_strictness":{"type":"string","enum":["lenient","reject_unverified","require_verified"],"description":"Defaults to \"reject_unverified\".","example":"reject_unverified"}},"required":["slug","display_name","issuer_url","client_id","client_auth_method"]},"UpdateSignInProviderRequest":{"type":"object","properties":{"slug":{"type":"string","maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"display_name":{"type":"string","minLength":1,"maxLength":200},"issuer_url":{"type":"string","maxLength":500,"format":"uri"},"client_id":{"type":"string","minLength":1,"maxLength":500},"enabled":{"type":"boolean"},"client_auth_method":{"type":"string","enum":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"]},"client_secret_env":{"type":["string","null"],"maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$","description":"Omit to keep the current value; null clears it."},"client_private_key_env":{"type":["string","null"],"maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$"},"client_assertion_algorithm":{"type":["string","null"],"minLength":1,"maxLength":20},"client_key_id":{"type":["string","null"],"minLength":1,"maxLength":200},"audience":{"type":["string","null"],"minLength":1,"maxLength":500},"request_resource":{"type":"boolean"},"introspection_client_id":{"type":["string","null"],"minLength":1,"maxLength":500,"description":"Introspect tokens as this client instead of the login client (an API server id, on a SchemaVaults auth server). The introspection_* fields are replaced together: send this with the others; null (on update) drops the identity."},"introspection_auth_method":{"type":["string","null"],"enum":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt",null]},"introspection_client_secret_env":{"type":["string","null"],"maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$"},"introspection_private_key_env":{"type":["string","null"],"maxLength":200,"pattern":"^[A-Z_][A-Z0-9_]*$"},"introspection_client_assertion_algorithm":{"type":["string","null"],"minLength":1,"maxLength":20},"introspection_client_key_id":{"type":["string","null"],"minLength":1,"maxLength":200},"login_scope":{"type":"string","minLength":1,"maxLength":500},"access_token_algorithms":{"type":"array","items":{"type":"string","minLength":1,"maxLength":20},"maxItems":20},"email_verification_strictness":{"type":"string","enum":["lenient","reject_unverified","require_verified"],"description":"How the provider's `email_verified` claim gates claiming a pending email link at sign-in: `lenient` links regardless, `reject_unverified` links unless the claim is explicitly false, `require_verified` links only when it is explicitly true.","example":"reject_unverified"}}},"UserProfile":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"display_name":{"type":["string","null"],"description":"Canonical profile, refreshed from ID-token claims at each sign-in."},"email":{"type":["string","null"]},"kind":{"type":"string","enum":["human","machine"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"identities":{"type":"array","items":{"$ref":"#/components/schemas/UserIdentity"},"description":"The account's sign-in methods, oldest first."}},"required":["id","display_name","email","kind","created_at","identities"]},"UserIdentity":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"provider_id":{"type":"string","format":"uuid"},"provider_slug":{"type":"string","example":"acme-sso"},"provider_name":{"type":"string","example":"Acme SSO"},"provider_enabled":{"type":"boolean","description":"A disabled provider's identities remain linked but cannot sign in."},"subject":{"type":"string","description":"The `sub` claim this identity presents at its provider.","example":"auth0|507f1f77bcf86cd799439011"},"last_authenticated_at":{"type":["string","null"],"format":"date-time","description":"When a sign-in or verified token last presented this identity."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","provider_id","provider_slug","provider_name","provider_enabled","subject","last_authenticated_at","created_at"]},"CreatedUser":{"type":"object","properties":{"user":{"$ref":"#/components/schemas/UserAccount"},"email_link":{"$ref":"#/components/schemas/UserEmailLink"}},"required":["user","email_link"]},"UserAccount":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"display_name":{"type":["string","null"]},"email":{"type":["string","null"]},"kind":{"type":"string","enum":["human","machine"]},"disabled_at":{"type":["string","null"],"format":"date-time","description":"When a superuser disabled the account; null while it can sign in. A disabled account keeps everything it owns but no sign-in or token resolves to it."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","display_name","email","kind","disabled_at","created_at","updated_at"]},"UserEmailLink":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"provider_id":{"type":"string","format":"uuid"},"provider_slug":{"type":"string","example":"acme-sso"},"provider_name":{"type":"string","example":"Acme SSO"},"provider_enabled":{"type":"boolean"},"email":{"type":"string","description":"Normalized (trimmed, lowercased) address the link matches.","example":"ada@example.com"},"created_by":{"type":["string","null"],"format":"uuid","description":"The superuser who created the link; null once that account is gone."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"linked_at":{"type":["string","null"],"format":"date-time","description":"When a sign-in claimed the link and attached its identity; null while pending."}},"required":["id","provider_id","provider_slug","provider_name","provider_enabled","email","created_by","created_at","linked_at"],"description":"The pending link that will catch the account's first sign-in."},"CreateUserRequest":{"type":"object","properties":{"display_name":{"type":["string","null"],"minLength":1,"maxLength":200,"description":"Starting display name; the first sign-in's name claim refreshes it.","example":"Ada Lovelace"},"email":{"type":"string","minLength":3,"maxLength":300,"description":"The address the person will sign in with; compared case-insensitively.","example":"ada@example.com"},"provider_id":{"type":"string","minLength":1,"description":"Sign-in provider UUID id or slug the person is expected to sign in with.","example":"acme-sso"}},"required":["email","provider_id"]},"CreateEmailLinkRequest":{"type":"object","properties":{"email":{"type":"string","minLength":3,"maxLength":300,"description":"The address the person will sign in with; compared case-insensitively.","example":"ada@example.com"},"provider_id":{"type":"string","minLength":1,"description":"Sign-in provider UUID id or slug the person is expected to sign in with.","example":"acme-sso"}},"required":["email","provider_id"]},"UpdateUserRequest":{"type":"object","properties":{"display_name":{"type":["string","null"],"minLength":1,"maxLength":200,"description":"The name shown for the account everywhere; null clears it.","example":"Ada Lovelace"},"disabled":{"type":"boolean","description":"true switches the account off: its sessions end on their next page load, its tokens stop resolving, and sign-in is refused, while everything it owns stays. false switches it back on. A caller cannot disable their own account.","example":true}}},"AttachIdentityRequest":{"type":"object","properties":{"provider_id":{"type":"string","minLength":1,"description":"Sign-in provider UUID id or slug.","example":"acme-sso"},"subject":{"type":"string","minLength":1,"maxLength":255,"description":"The `sub` claim the account's owner presents at that provider."}},"required":["provider_id","subject"]},"MergeUsersResponse":{"type":"object","properties":{"merged_user_id":{"type":"string","format":"uuid","description":"The absorbed (now tombstoned) account."},"into_user_id":{"type":"string","format":"uuid"}},"required":["merged_user_id","into_user_id"]},"MergeUsersRequest":{"type":"object","properties":{"into_user_id":{"type":"string","format":"uuid","description":"The surviving account everything is repointed at."}},"required":["into_user_id"]},"CalendarFeedTokenList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CalendarFeedToken"},"description":"The caller's live links, newest first; with include_revoked, followed by those revoked in the last 90 days, most recent first."},"can_include_team":{"type":"boolean","description":"Whether the caller manages a role, and so may give a link a team scope. A team link of a caller for whom this is false serves self content."},"limit":{"type":"integer","description":"How many live links the caller may hold at once.","example":10}},"required":["items","can_include_team","limit"]},"CalendarFeedToken":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string","example":"Work Outlook"},"scope":{"type":"string","enum":["self","team","team_schedule"],"description":"What the feed serves beyond the owner's own deadlines, training events and live quiz sittings. self: nothing more. team: plus their trainees' certification due dates. team_schedule: plus their trainees' due dates, training events and live quiz sittings. The team scopes need the owner to manage a role; while they manage none, a team link serves self content.","example":"self"},"token_hint":{"type":"string","description":"The last four characters of the link's URL token, to match this row to the URL a calendar app shows in its subscription settings.","example":"a1B9"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"last_used_at":{"type":["string","null"],"format":"date-time","description":"When a calendar last fetched the feed, recorded at most every fifteen minutes; null until the first fetch."},"last_user_agent":{"type":["string","null"],"description":"The User-Agent of that fetch, cut to 200 characters.","example":"Microsoft Office/16.0"},"revoked_at":{"type":["string","null"],"format":"date-time","description":"When the link was revoked; a revoked link never serves again."}},"required":["id","label","scope","token_hint","created_at","last_used_at","last_user_agent","revoked_at"]},"CreatedCalendarFeedToken":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string","example":"Work Outlook"},"scope":{"type":"string","enum":["self","team","team_schedule"],"description":"What the feed serves beyond the owner's own deadlines, training events and live quiz sittings. self: nothing more. team: plus their trainees' certification due dates. team_schedule: plus their trainees' due dates, training events and live quiz sittings. The team scopes need the owner to manage a role; while they manage none, a team link serves self content.","example":"self"},"token_hint":{"type":"string","description":"The last four characters of the link's URL token, to match this row to the URL a calendar app shows in its subscription settings.","example":"a1B9"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"last_used_at":{"type":["string","null"],"format":"date-time","description":"When a calendar last fetched the feed, recorded at most every fifteen minutes; null until the first fetch."},"last_user_agent":{"type":["string","null"],"description":"The User-Agent of that fetch, cut to 200 characters.","example":"Microsoft Office/16.0"},"revoked_at":{"type":["string","null"],"format":"date-time","description":"When the link was revoked; a revoked link never serves again."},"feed_url":{"type":"string","format":"uri","description":"The feed itself. Anyone holding this URL reads the feed until the link is revoked, and it is never shown again.","example":"https://lrs.example.com/api/calendar-feeds/lrscal_…/calendar.ics"},"webcal_url":{"type":"string","description":"The same URL with the webcal:// scheme, for desktop and Apple calendars."},"outlook_web_url":{"type":"string","format":"uri","description":"Subscribes Outlook on the web (work or school accounts) to the feed."},"outlook_live_url":{"type":"string","format":"uri","description":"Subscribes Outlook.com (personal accounts) to the feed."}},"required":["id","label","scope","token_hint","created_at","last_used_at","last_user_agent","revoked_at","feed_url","webcal_url","outlook_web_url","outlook_live_url"]},"CreateCalendarFeedTokenRequest":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":80,"description":"The owner's name for the link, to tell their calendars apart.","example":"Work Outlook"},"scope":{"type":"string","enum":["self","team","team_schedule"],"description":"Defaults to self.","example":"self"}},"required":["label"]},"RevokeAllCalendarFeedTokensResponse":{"type":"object","properties":{"revoked_count":{"type":"integer","minimum":0,"description":"How many live links were revoked.","example":2}},"required":["revoked_count"]},"UpdateCalendarFeedTokenRequest":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":80,"description":"The owner's name for the link, to tell their calendars apart.","example":"Work Outlook"},"scope":{"type":"string","enum":["self","team","team_schedule"],"description":"What the feed serves beyond the owner's own deadlines, training events and live quiz sittings. self: nothing more. team: plus their trainees' certification due dates. team_schedule: plus their trainees' due dates, training events and live quiz sittings. The team scopes need the owner to manage a role; while they manage none, a team link serves self content.","example":"self"}}},"SubjectMatterList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SubjectMatter"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"SubjectMatter":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the subject."},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","owner_organization_id","owner_department_id","slug","name","description","archived_at","created_at"]},"CreateSubjectMatterRequest":{"type":"object","properties":{"owner_organization_id":{"type":"string","format":"uuid","description":"Organization the subject belongs to. Always required."},"owner_department_id":{"type":"string","format":"uuid","description":"Omit for a subject owned by the organization directly."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"forklift-safety","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like forklift-safety."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Forklift safety"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."},"parent_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Broader subjects of the same organization to file the new one beneath; omit for a top-level subject."}},"required":["owner_organization_id","slug","name"]},"UpdateSubjectMatterRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null to clear the description."}}},"SubjectMatterHierarchy":{"type":"object","properties":{"subject":{"$ref":"#/components/schemas/SubjectMatter"},"parents":{"type":"array","items":{"$ref":"#/components/schemas/SubjectMatterRef"},"description":"The broader subjects this one sits directly beneath, alphabetically."},"ancestors":{"type":"array","items":{"$ref":"#/components/schemas/SubjectMatterRef"},"description":"Every subject above this one at any depth — the parents, their parents, and so on — each once, alphabetically."},"children":{"type":"array","items":{"$ref":"#/components/schemas/SubjectMatterRef"},"description":"The narrower subjects directly beneath this one, alphabetically."}},"required":["subject","parents","ancestors","children"]},"SubjectMatterRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."}},"required":["id","slug","name","archived_at"]},"SetSubjectMatterParentsRequest":{"type":"object","properties":{"parent_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the subject's parents. Each must be a subject of the same organization, and none may be the subject itself or one already beneath it."}},"required":["parent_ids"]},"LearningRecordSubmission":{"type":"object","properties":{"record":{"$ref":"#/components/schemas/LearningRecord"},"evidence":{"type":"array","items":{"$ref":"#/components/schemas/SupportingEvidence"},"description":"The created evidence rows, in the order they were submitted."}},"required":["record","evidence"]},"LearningRecord":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the record."},"learner_user_id":{"type":"string","format":"uuid","description":"`users.id` of the learner the record is about.","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"learner_display_name":{"type":["string","null"],"description":"Snapshot taken at write time; the IdP is not queryable for it later."},"learner_email":{"type":["string","null"]},"title":{"type":"string"},"description":{"type":["string","null"]},"status":{"type":"string","enum":["pending","rejected","in_progress","completed","expired","revoked"]},"completed_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"external_id":{"type":["string","null"],"description":"Importer-assigned id, unique per owning organization."},"recorded_by":{"type":["string","null"],"format":"uuid","description":"`users.id` of the writer, which is not always the learner."},"approved_by":{"type":["string","null"],"format":"uuid","description":"`users.id` of the manager who approved the record out of pending; null otherwise."},"approved_at":{"type":["string","null"],"format":"date-time"},"rejected_by":{"type":["string","null"],"format":"uuid","description":"`users.id` of the manager who rejected the record back to its learner; set exactly while the status is rejected."},"rejected_at":{"type":["string","null"],"format":"date-time"},"rejection_note":{"type":["string","null"],"description":"The rejecter's note: what to fix before resubmitting."},"hidden_from_learner":{"type":"boolean","description":"True when the learner's own-records read exemption does not apply: only records:read over the owner (or superuser) reads the record, and to the learner without such a grant it does not exist. Never true on a pending or rejected record."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/RecordSubjectRef"}},"evidence_count":{"type":"integer"},"hours":{"type":"number","description":"Hours of evidence the record carries, as an hours rule counts them: quantity evidence in hour or minute units, and the span of date_range evidence. 0 when none of its evidence measures time.","example":6.5},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","owner_organization_id","owner_department_id","learner_user_id","learner_display_name","learner_email","title","description","status","completed_at","expires_at","external_id","recorded_by","approved_by","approved_at","rejected_by","rejected_at","rejection_note","hidden_from_learner","subjects","evidence_count","hours","archived_at","created_at"]},"RecordSubjectRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"archived_at":{"type":["string","null"],"format":"date-time"}},"required":["id","slug","name","archived_at"]},"SupportingEvidence":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"learning_record_id":{"type":"string","format":"uuid"},"evidence_type":{"type":"string","enum":["score","date_range","quantity","url","file","pass_fail","note"]},"label":{"type":["string","null"]},"data":{"$ref":"#/components/schemas/EvidencePayload"},"occurred_at":{"type":["string","null"],"format":"date-time"},"period_start":{"type":["string","null"],"format":"date-time"},"period_end":{"type":["string","null"],"format":"date-time"},"numeric_value":{"type":["number","null"]},"numeric_unit":{"type":["string","null"]},"boolean_value":{"type":["boolean","null"]},"text_value":{"type":["string","null"]},"blob_pathname":{"type":["string","null"],"description":"Where the private blob lives, for file evidence. Not itself a URL."},"recorded_by":{"type":["string","null"],"format":"uuid","description":"`users.id` of the writer."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","learning_record_id","evidence_type","label","data","occurred_at","period_start","period_end","numeric_value","numeric_unit","boolean_value","text_value","blob_pathname","recorded_by","created_at"]},"EvidencePayload":{"oneOf":[{"type":"object","properties":{"evidence_type":{"type":"string","enum":["score"]},"occurred_at":{"type":["string","null"],"format":"date-time","description":"When the thing being evidenced happened, if it happened at a point in time."},"score":{"type":"number"},"max":{"type":"number","exclusiveMinimum":0},"scale":{"type":["string","null"],"minLength":1}},"required":["evidence_type","score","max"]},{"type":"object","properties":{"evidence_type":{"type":"string","enum":["date_range"]},"occurred_at":{"type":["string","null"],"format":"date-time","description":"When the thing being evidenced happened, if it happened at a point in time."},"start":{"type":"string","format":"date-time"},"end":{"type":"string","format":"date-time"}},"required":["evidence_type","start","end"]},{"type":"object","properties":{"evidence_type":{"type":"string","enum":["quantity"]},"occurred_at":{"type":["string","null"],"format":"date-time","description":"When the thing being evidenced happened, if it happened at a point in time."},"value":{"type":"number"},"unit":{"type":"string","minLength":1}},"required":["evidence_type","value","unit"]},{"type":"object","properties":{"evidence_type":{"type":"string","enum":["url"]},"occurred_at":{"type":["string","null"],"format":"date-time","description":"When the thing being evidenced happened, if it happened at a point in time."},"url":{"type":"string","format":"uri"},"label":{"type":["string","null"],"minLength":1}},"required":["evidence_type","url"]},{"type":"object","properties":{"evidence_type":{"type":"string","enum":["file"]},"occurred_at":{"type":["string","null"],"format":"date-time","description":"When the thing being evidenced happened, if it happened at a point in time."},"file_name":{"type":"string","minLength":1},"content_type":{"type":"string","enum":["application/pdf","image/png","image/jpeg","image/webp"]},"size_bytes":{"type":"integer","exclusiveMinimum":0,"maximum":26214400},"checksum":{"type":["string","null"],"minLength":1}},"required":["evidence_type","file_name","content_type","size_bytes"]},{"type":"object","properties":{"evidence_type":{"type":"string","enum":["pass_fail"]},"occurred_at":{"type":["string","null"],"format":"date-time","description":"When the thing being evidenced happened, if it happened at a point in time."},"passed":{"type":"boolean"},"criteria":{"type":["string","null"],"minLength":1}},"required":["evidence_type","passed"]},{"type":"object","properties":{"evidence_type":{"type":"string","enum":["note"]},"occurred_at":{"type":["string","null"],"format":"date-time","description":"When the thing being evidenced happened, if it happened at a point in time."},"text":{"type":"string","minLength":1}},"required":["evidence_type","text"]}]},"LearningRecordSubmissionForm":{"type":"object","properties":{"record":{"type":"string","minLength":1,"description":"JSON-encoded record, in the shape of the CreateLearningRecordRequest schema. Callers without records:write over the owner must use status \"pending\" and omit learner_user_id (the record is about themselves).","example":"{\"owner_organization_id\":\"1b671a64-40d5-491e-99b0-da01ff1f3341\",\"title\":\"Simulator session\",\"status\":\"pending\",\"completed_at\":\"2026-01-01T00:00:00.000Z\",\"subject_matter_ids\":[\"6b3f7a52-8a20-4f0e-9be1-0f6cbd2f2f5c\"]}"},"evidence":{"type":"string","minLength":1,"description":"JSON-encoded array of evidence, each entry in the shape of the CreateSupportingEvidenceRequest schema (without pathname — a file travels in the file part instead). At most one entry may be of evidence_type file.","example":"[{\"label\":\"Session length\",\"payload\":{\"evidence_type\":\"quantity\",\"value\":2.5,\"unit\":\"hours\"}}]"},"file":{"type":"string","format":"binary","description":"The bytes of the one file evidence entry, sent as a file part. Required exactly when the evidence array contains a file entry; the part's size and content type must match what that entry's payload declares."}},"required":["record","evidence"]},"LearningRecordList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/LearningRecord"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"CreateLearningRecordRequest":{"type":"object","properties":{"owner_organization_id":{"type":"string","format":"uuid","description":"Organization the record belongs to. Always required."},"owner_department_id":{"type":"string","format":"uuid","description":"Omit for a record owned by the organization directly."},"learner_user_id":{"type":"string","format":"uuid","description":"`users.id` of the learner. Omit to record about yourself — the snapshots below then default to your own profile.","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"learner_display_name":{"type":"string","minLength":1,"maxLength":300},"learner_email":{"type":"string","minLength":1,"maxLength":300},"title":{"type":"string","minLength":1,"maxLength":300,"example":"Forklift operator certification"},"description":{"type":"string","maxLength":5000},"status":{"type":"string","enum":["pending","in_progress","completed","expired","revoked"],"default":"completed","description":"Callers without records:write over the owner may only create pending records about themselves. rejected cannot be set directly — it is entered through the reject endpoint."},"completed_at":{"type":["string","null"],"format":"date-time","description":"When the training or assessment happened. Required on a pending submission about yourself: a recertification window counts only records completed after the current award, so an undated one could never count."},"expires_at":{"type":["string","null"],"format":"date-time","description":"Must be after completed_at when both are set."},"external_id":{"type":"string","minLength":1,"maxLength":300,"description":"Importer-assigned id; unique per owning organization, so re-runs are idempotent."},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"default":[],"description":"Subjects to file the record under; must belong to the same organization."},"hidden_from_learner":{"type":"boolean","default":false,"description":"Hide the record from the learner it is about: they can then read it only through records:read over the owner. Needs records:write over the owner — a self-service submission cannot be hidden — and a status other than pending or rejected."}},"required":["owner_organization_id","title"]},"UpdateLearningRecordRequest":{"type":"object","properties":{"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Move the record to another department of the same organization; null hands it to the organization directly."},"learner_display_name":{"type":["string","null"],"minLength":1,"maxLength":300},"learner_email":{"type":["string","null"],"minLength":1,"maxLength":300},"title":{"type":"string","minLength":1,"maxLength":300},"description":{"type":["string","null"],"maxLength":5000},"status":{"type":"string","enum":["pending","in_progress","completed","expired","revoked"],"description":"rejected cannot be set directly — use the reject endpoint. The learner of a rejected record may set it back to pending, which resubmits it and clears the rejection."},"completed_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"external_id":{"type":["string","null"],"minLength":1,"maxLength":300},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"When present, replaces the record's whole subject set."},"hidden_from_learner":{"type":"boolean","description":"Hide the record from the learner it is about: they can then read it only through records:read over the owner. Needs records:write over the owner — a self-service submission cannot be hidden — and a status other than pending or rejected."}}},"ApproveLearningRecordRequest":{"type":"object","properties":{"status":{"type":"string","enum":["completed","in_progress"],"default":"completed","description":"What the record becomes on approval: completed (default), or in_progress for learning that is still under way."}}},"RejectLearningRecordRequest":{"type":"object","properties":{"note":{"type":"string","minLength":1,"maxLength":2000,"description":"Why the submission is rejected — what the learner should fix before resubmitting. Shown to the learner on the record.","example":"The simulator hours need the instructor's session log attached."}},"required":["note"]},"CreateSupportingEvidenceRequest":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":300,"description":"Human label for the measurement, e.g. \"Final theory exam\"."},"payload":{"$ref":"#/components/schemas/EvidencePayload"},"pathname":{"type":"string","minLength":1,"description":"For file evidence only: the pathname issued by the uploads endpoint, after the file has been PUT there."}},"required":["payload"]},"UploadTicket":{"type":"object","properties":{"evidence_id":{"type":"string","format":"uuid","description":"Minted for the future evidence row; embedded in the pathname."},"pathname":{"type":"string"},"upload_url":{"type":"string","description":"PUT the file bytes here, with the declared content type."},"expires_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["evidence_id","pathname","upload_url","expires_at"]},"CreateUploadTicketRequest":{"type":"object","properties":{"file_name":{"type":"string","minLength":1,"maxLength":300},"content_type":{"type":"string","enum":["application/pdf","image/png","image/jpeg","image/webp"]},"size_bytes":{"type":"integer","exclusiveMinimum":0,"maximum":26214400}},"required":["file_name","content_type","size_bytes"]},"UpdateSupportingEvidenceRequest":{"type":"object","properties":{"label":{"type":["string","null"],"minLength":1,"maxLength":300,"description":"Send null to clear the label."},"payload":{"allOf":[{"$ref":"#/components/schemas/EvidencePayload"},{"description":"Replaces the measurement. Must keep the row's evidence_type; file payloads cannot be replaced — delete the row and upload again instead."}]}}},"DownloadTicket":{"type":"object","properties":{"url":{"type":"string","description":"Short-lived presigned GET for the private blob. Not stored, not logged."},"expires_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"file_name":{"type":"string"},"content_type":{"type":"string"}},"required":["url","expires_at","file_name","content_type"]},"UserCertificationStatusList":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid"},"items":{"type":"array","items":{"$ref":"#/components/schemas/UserCertificationStatus"}}},"required":["user_id","items"]},"UserCertificationStatus":{"type":"object","properties":{"certification_type":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"awarding_mode":{"type":"string","enum":["automatic","approval"]}},"required":["id","owner_organization_id","owner_department_id","slug","name","description","awarding_mode"]},"subject_matters":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"}},"required":["id","slug","name"]},"description":"The type's subject-matter tags, alphabetically; a record filed under any of them counts toward its record-based rules."},"required_by_roles":{"type":"array","items":{"$ref":"#/components/schemas/CertificationRequiredByRole"},"description":"Every unexpired role grant of the user that requires this type."},"current_award":{"$ref":"#/components/schemas/CertificationAward"},"due_at":{"type":["string","null"],"format":"date-time","description":"When the certification (or the next recertification) falls due; null when no initial due date is set or inherited, and no recert scheme applies."},"due_source":{"type":["string","null"],"enum":["initial","role","recert",null],"description":"initial: the admin-set per-person due date. role: inherited from the earliest role-level initial due date across the requiring roles. recert: computed from the current award and the strictest recert scheme across the requiring roles."},"overdue":{"type":"boolean"},"rules":{"type":"array","items":{"$ref":"#/components/schemas/CertificationRuleProgress"},"description":"Progress per proof rule, evaluated since the current award (or over all history while uncertified)."},"all_rules_satisfied":{"type":"boolean"},"pending_request_id":{"type":["string","null"],"format":"uuid","description":"The user's pending request for this type, if one is open."},"pending_submission_count":{"type":"integer","description":"The user's learning-record submissions on the type's subjects still awaiting a manager's approval, within the evaluation window — counting only submissions whose approval would advance at least one of the type's rules. The progress they would grant is carried per rule as pending_current / pending_evidence_present."},"rejected_submission_count":{"type":"integer","description":"The user's learning-record submissions on the type's subjects a manager sent back (status rejected), within the evaluation window — counting only submissions that would have advanced at least one of the type's rules. They count for nothing until the learner fixes and resubmits them."}},"required":["certification_type","subject_matters","required_by_roles","current_award","due_at","due_source","overdue","rules","all_rules_satisfied","pending_request_id","pending_submission_count","rejected_submission_count"]},"CertificationRequiredByRole":{"type":"object","properties":{"link_id":{"type":"string","format":"uuid"},"role_id":{"type":"string","format":"uuid"},"role_name":{"type":"string"},"department_id":{"type":"string","format":"uuid"},"department_name":{"type":"string"},"organization_id":{"type":"string","format":"uuid"},"organization_name":{"type":"string"},"recert_scheme":{"type":"string","enum":["none","anniversary","fixed_calendar"]},"recert_interval_months":{"type":["number","null"]},"recert_anchor_month":{"type":["number","null"]},"recert_anchor_day":{"type":["number","null"]},"initial_due_at":{"type":["string","null"],"format":"date-time","description":"The link's role-level initial due date; null when the role sets no deadline."}},"required":["link_id","role_id","role_name","department_id","department_name","organization_id","organization_name","recert_scheme","recert_interval_months","recert_anchor_month","recert_anchor_day","initial_due_at"]},"CertificationAward":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"certification_type_id":{"type":"string","format":"uuid"},"award_kind":{"type":"string","enum":["claimed","approved","granted"]},"awarded_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"awarded_by":{"type":["string","null"],"format":"uuid","description":"Null on a self-claimed award."},"revoked_at":{"type":["string","null"],"format":"date-time"},"revoked_by":{"type":["string","null"],"format":"uuid"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","organization_id","user_id","certification_type_id","award_kind","awarded_at","awarded_by","revoked_at","revoked_by","created_at"],"description":"The latest non-revoked award, or null while the user is uncertified."},"CertificationRuleProgress":{"type":"object","properties":{"rule_id":{"type":"string","format":"uuid"},"rule_kind":{"type":"string","enum":["subject_hours","subject_record_count","evidence_document","manual_sign_off","quiz_pass","external_course_pass","scorm_course_complete","job_aid_viewed"]},"label":{"type":["string","null"]},"current":{"type":["number","null"],"description":"Hours or completed records accumulated in the evaluation window, for the threshold rule kinds; null for the others."},"pending_current":{"type":["number","null"],"description":"Hours or records the user's still-pending submissions in the window would add once a manager approves them, for the threshold rule kinds; null for the others."},"target":{"type":["number","null"],"description":"The rule's threshold, for the threshold rule kinds; null for the others."},"subject_matter_id":{"type":["string","null"],"format":"uuid","description":"The one subject a threshold rule is narrowed to; null when it counts every tag of the type, and always null on the other kinds."},"subject_name":{"type":["string","null"],"description":"That subject's name, for display; null whenever subject_matter_id is."},"evidence_type":{"type":["string","null"],"enum":["score","date_range","quantity","url","file","pass_fail","note",null],"description":"The evidence type an evidence_document rule looks for."},"evidence_present":{"type":["boolean","null"],"description":"Whether a completed record in the window carries that evidence (for pass_fail, a row recording a pass); null for other kinds."},"pending_evidence_present":{"type":["boolean","null"],"description":"Whether a still-pending submission in the window carries that evidence; null for other kinds."},"last_sign_off_at":{"type":["string","null"],"format":"date-time","description":"Most recent sign-off for a manual_sign_off rule, window regardless."},"quiz_id":{"type":["string","null"],"format":"uuid","description":"The quiz a quiz_pass rule requires; null for other kinds."},"quiz_slug":{"type":["string","null"]},"quiz_name":{"type":["string","null"]},"last_quiz_pass_at":{"type":["string","null"],"format":"date-time","description":"Most recent passing attempt for a quiz_pass rule, window regardless."},"external_course_id":{"type":["string","null"],"format":"uuid","description":"Catalogue id of the course an external_course_pass rule requires; null for other kinds."},"external_course_name":{"type":["string","null"]},"external_course_code":{"type":["string","null"],"description":"The LMS's own identifier of that course."},"external_server_name":{"type":["string","null"]},"external_course_url":{"type":["string","null"],"format":"uri","description":"That course's page on its LMS, as the catalogue records it — the learner's way to a course that lives outside this system. Null when the catalogue has no URL for it, and on other kinds."},"last_course_pass_at":{"type":["string","null"],"format":"date-time","description":"Most recent passing grade for an external_course_pass rule, window regardless."},"scorm_course_id":{"type":["string","null"],"format":"uuid","description":"The hosted SCORM course a scorm_course_complete rule requires; null for other kinds."},"scorm_course_name":{"type":["string","null"]},"scorm_course_archived":{"type":["boolean","null"],"description":"Whether that course is archived — hidden from learners, so no longer takeable; null for other kinds."},"last_scorm_complete_at":{"type":["string","null"],"format":"date-time","description":"Most recent counting completion for a scorm_course_complete rule, window regardless: an attempt reported complete without an explicit failed verdict."},"scorm_course_recertification_required_at":{"type":["string","null"],"format":"date-time","description":"When that course's package was last replaced with recertification required; completions of earlier package versions no longer satisfy the rule. Null while every completion counts, and on other kinds."},"scorm_completion_superseded":{"type":["boolean","null"],"description":"Whether last_scorm_complete_at was of a package version before the one that required recertification, so it does not satisfy the rule whatever the window. False while no completion is on record; null on other kinds."},"job_aid_id":{"type":["string","null"],"format":"uuid","description":"The job aid a job_aid_viewed rule requires opening; null for other kinds."},"job_aid_title":{"type":["string","null"]},"job_aid_archived":{"type":["boolean","null"],"description":"Whether that aid is archived — hidden from its audience, so no longer openable; null for other kinds."},"last_job_aid_view_at":{"type":["string","null"],"format":"date-time","description":"Most recent open of the aid's page for a job_aid_viewed rule, window regardless. Opens of its files do not count."},"job_aid_recertification_required_at":{"type":["string","null"],"format":"date-time","description":"That aid's recertification cutoff, when its content was replaced with recertification required: an open before it does not satisfy the rule, whatever the window. Null while every open counts, and on other kinds."},"supporting_records":{"type":["array","null"],"items":{"$ref":"#/components/schemas/CertificationSupportingRecord"},"description":"The user's learning records in the evaluation window that count towards a subject_hours, subject_record_count or evidence_document rule, or would once approved — most recent first, each with its status, so a learner can see which records stand behind the rule and which are still awaiting a manager. Records a narrowed threshold rule does not cover are left out. Null for the other kinds."},"satisfied":{"type":"boolean"}},"required":["rule_id","rule_kind","label","current","pending_current","target","subject_matter_id","subject_name","evidence_type","evidence_present","pending_evidence_present","last_sign_off_at","quiz_id","quiz_slug","quiz_name","last_quiz_pass_at","external_course_id","external_course_name","external_course_code","external_server_name","external_course_url","last_course_pass_at","scorm_course_id","scorm_course_name","scorm_course_archived","last_scorm_complete_at","scorm_course_recertification_required_at","scorm_completion_superseded","job_aid_id","job_aid_title","job_aid_archived","last_job_aid_view_at","job_aid_recertification_required_at","supporting_records","satisfied"]},"CertificationSupportingRecord":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"The learning record's id."},"title":{"type":["string","null"],"description":"Null when redacted."},"redacted":{"type":"boolean","description":"True for a record hidden from its learner that the caller cannot read — their records:read does not cover its owner. It still counts, with the status, date, hours and evidence below, but carries no title and its page answers 404."},"status":{"type":"string","enum":["completed","pending","rejected"],"description":"completed: approved and counting. pending: awaiting a manager's decision — it counts once approved. rejected: sent back; it counts for nothing until resubmitted."},"completed_at":{"type":["string","null"],"format":"date-time"},"hours":{"type":"number","description":"Hours of evidence the record carries, as an hours rule counts them: quantity evidence in hour or minute units, and the span of date_range evidence."},"evidence_types":{"type":"array","items":{"type":"string","enum":["score","date_range","quantity","url","file","pass_fail","note"]},"description":"The evidence types present on the record, as a document rule sees them — a pass_fail row only when it records a pass."}},"required":["id","title","redacted","status","completed_at","hours","evidence_types"]},"CertificationRequest":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"],"description":"The requester's profile name, when their account carries one."},"user_email":{"type":["string","null"]},"certification_type_id":{"type":"string","format":"uuid"},"certification_type":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"awarding_mode":{"type":"string","enum":["automatic","approval"]}},"required":["id","slug","name","awarding_mode"],"description":"The requested certification type, for display."},"status":{"type":"string","enum":["pending","approved","rejected","withdrawn"]},"message":{"type":["string","null"]},"decided_by":{"type":["string","null"],"format":"uuid"},"decided_at":{"type":["string","null"],"format":"date-time"},"decision_note":{"type":["string","null"]},"resulting_certification_id":{"type":["string","null"],"format":"uuid","description":"The award an approval produced; set exactly when status is approved."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","organization_id","user_id","user_display_name","user_email","certification_type_id","certification_type","status","message","decided_by","decided_at","decision_note","resulting_certification_id","created_at"]},"RequestCertificationRequest":{"type":"object","properties":{"message":{"type":"string","maxLength":2000,"description":"Optional note to whoever decides the request."}}},"DecideCertificationRequestRequest":{"type":"object","properties":{"decision_note":{"type":"string","maxLength":2000,"description":"Optional note recorded with the decision, shown to the requester."}}},"CertificationRequestList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CertificationRequest"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"GrantCertificationRequest":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"`users.id` of the user the certification is granted to."},"awarded_at":{"type":"string","format":"date-time","description":"Omit for now; set to backdate a grant, e.g. one imported from paper."}},"required":["user_id"]},"CertificationAwardList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CertificationAward"}}},"required":["items"]},"CertificationDueDateList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CertificationDueDate"}}},"required":["items"]},"CertificationDueDate":{"type":"object","properties":{"organization_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"certification_type_id":{"type":"string","format":"uuid"},"initial_due_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z","description":"When the user's *initial* certification falls due; recerts are computed. Overrides any role-level initial due date."},"set_by":{"type":["string","null"],"format":"uuid"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["organization_id","user_id","certification_type_id","initial_due_at","set_by","created_at"]},"SetCertificationDueDateRequest":{"type":"object","properties":{"initial_due_at":{"type":"string","format":"date-time","description":"When this user's initial certification falls due. Takes precedence over any role-level initial due date, earlier or later alike."}},"required":["initial_due_at"]},"CertificationSignOff":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"requirement_id":{"type":"string","format":"uuid"},"certification_type_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"signed_off_by":{"type":"string","format":"uuid"},"signed_off_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"note":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","requirement_id","certification_type_id","user_id","signed_off_by","signed_off_at","note","created_at"]},"CreateCertificationSignOffRequest":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"`users.id` of the user being signed off."},"signed_off_at":{"type":"string","format":"date-time","description":"Omit for now; set to backdate a sign-off."},"note":{"type":"string","maxLength":2000}},"required":["user_id"]},"CertificationSignOffList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CertificationSignOff"}}},"required":["items"]},"CertificationTypeList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CertificationType"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"CertificationType":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the certification type."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/CertificationTypeSubject"},"description":"The subject matters whose records and evidence satisfy the rules, alphabetically; a record filed under any of them counts. Empty for a type proven by sign-offs, quizzes or external courses alone."},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"description_markdown":{"type":["string","null"],"description":"Long-form narrative markdown below the summary line; never parsed."},"awarding_mode":{"type":"string","enum":["automatic","approval"],"description":"automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","owner_organization_id","owner_department_id","subjects","slug","name","description","description_markdown","awarding_mode","archived_at","created_at"]},"CertificationTypeSubject":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"}},"required":["id","slug","name"]},"CreateCertificationTypeRequest":{"type":"object","properties":{"owner_organization_id":{"type":"string","format":"uuid","description":"Organization the certification type belongs to. Always required."},"owner_department_id":{"type":"string","format":"uuid","description":"Omit for a type owned by the organization directly."},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Subject matters of the same organization to tag the type with; records filed under any of them satisfy its record-based rules. Omit for an untagged type; the set can be replaced later through PUT /certifications/{cert_id}/subjects."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"forklift-operator","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like forklift-operator."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Certified forklift operator"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."},"description_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no long-form markdown description."},"awarding_mode":{"type":"string","enum":["automatic","approval"],"description":"automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides."}},"required":["owner_organization_id","slug","name","awarding_mode"]},"DescriptionUploadTicket":{"type":"object","properties":{"pathname":{"type":"string","description":"Where the private blob will live; pass it back when creating the attachment. Not itself a URL."},"upload_url":{"type":"string","description":"PUT the file bytes here, with the declared content type."},"expires_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["pathname","upload_url","expires_at"]},"CreateDescriptionUploadRequest":{"type":"object","properties":{"file_name":{"type":"string","minLength":1,"maxLength":300},"content_type":{"type":"string","minLength":1,"maxLength":200,"description":"The file's content type; any type is allowed, but the upload pins this one."},"size_bytes":{"type":"integer","exclusiveMinimum":0,"maximum":524288000}},"required":["file_name","content_type","size_bytes"]},"DescriptionAttachment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"file_name":{"type":"string","description":"The uploaded file's original name, kept verbatim for downloads."},"content_type":{"type":"string","description":"The content type the store recorded at upload; any type is allowed."},"label":{"type":["string","null"],"description":"Admin-written label shown in place of the file name, or null."},"position":{"type":"integer","description":"Authored order the attachments render in."},"prefer_inline_display":{"type":"boolean","description":"Whether to show the file on the page — a PDF or image drawn inline, video and audio in a player — or offer it as a download only. Files meant to be filled in or worked on locally set this false so they do not bury the rest of the page. A download is offered either way."}},"required":["id","file_name","content_type","label","position","prefer_inline_display"]},"CreateDescriptionAttachmentRequest":{"type":"object","properties":{"pathname":{"type":"string","minLength":1,"description":"The pathname an upload ticket was issued for, after PUTting the file."},"file_name":{"type":"string","minLength":1,"maxLength":300},"label":{"type":"string","maxLength":200,"description":"Omit or send empty to show the file name instead."},"prefer_inline_display":{"type":"boolean","description":"Omit to show the file on the page where its type allows it. Send false for a file meant to be downloaded and worked on locally — a blank form, a spreadsheet — so it renders as a download instead of an inline viewer."}},"required":["pathname","file_name"]},"UpdateDescriptionAttachmentRequest":{"type":"object","properties":{"label":{"type":["string","null"],"maxLength":200,"description":"Send null or empty to clear the label back to the file name."},"position":{"type":"integer","minimum":0,"description":"New slot in the authored order."},"prefer_inline_display":{"type":"boolean","description":"Switches the file between showing on the page and being offered as a download only."}}},"SetCertificationTypeSubjectsRequest":{"type":"object","properties":{"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the type's subject-matter tags; send an empty array to clear them."}},"required":["subject_matter_ids"]},"CertificationTypeDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the certification type."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/CertificationTypeSubject"},"description":"The subject matters whose records and evidence satisfy the rules, alphabetically; a record filed under any of them counts. Empty for a type proven by sign-offs, quizzes or external courses alone."},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"description_markdown":{"type":["string","null"],"description":"Long-form narrative markdown below the summary line; never parsed."},"awarding_mode":{"type":"string","enum":["automatic","approval"],"description":"automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"rules":{"type":"array","items":{"$ref":"#/components/schemas/CertificationRule"},"description":"The proof rules of this type; all of them must be satisfied at once."},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/DescriptionAttachment"},"description":"The type's description attachments, in authored order — e.g. a blank form to fill and submit as evidence. The bytes are fetched through GET /certifications/{cert_id}/attachments/{attachment_id}/download."}},"required":["id","owner_organization_id","owner_department_id","subjects","slug","name","description","description_markdown","awarding_mode","archived_at","created_at","rules","attachments"]},"CertificationRule":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"certification_type_id":{"type":"string","format":"uuid"},"rule_kind":{"type":"string","enum":["subject_hours","subject_record_count","evidence_document","manual_sign_off","quiz_pass","external_course_pass","scorm_course_complete","job_aid_viewed"],"description":"subject_hours: evidenced hours on the type's subjects (or on subject_matter_id alone) must reach threshold_hours. subject_record_count: completed records on the subjects (or on subject_matter_id alone) must reach threshold_count. evidence_document: a completed record on the subject must carry evidence of evidence_type (a pass_fail row counts only when it records a pass). manual_sign_off: a permission holder must record a sign-off for the rule. quiz_pass: the user must have a passing attempt of the quiz named by quiz_id. external_course_pass: the user must have a passing grade of the external LMS course named by external_course_id. scorm_course_complete: the user must have completed the hosted SCORM course named by scorm_course_id. job_aid_viewed: the user must have opened the job aid named by job_aid_id."},"label":{"type":["string","null"]},"threshold_hours":{"type":["number","null"]},"threshold_count":{"type":["number","null"]},"evidence_type":{"type":["string","null"],"enum":["score","date_range","quantity","url","file","pass_fail","note",null]},"quiz_id":{"type":["string","null"],"format":"uuid"},"external_course_id":{"type":["string","null"],"format":"uuid"},"scorm_course_id":{"type":["string","null"],"format":"uuid"},"job_aid_id":{"type":["string","null"],"format":"uuid"},"subject_matter_id":{"type":["string","null"],"format":"uuid","description":"The one subject a threshold rule counts, when narrowed; null counts every tag of the type, and always null on the other kinds."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","certification_type_id","rule_kind","label","threshold_hours","threshold_count","evidence_type","quiz_id","external_course_id","scorm_course_id","job_aid_id","subject_matter_id","created_at"]},"DuplicateCertificationTypeRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"The copy's name; omit for the original's with \" (copy)\" appended."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","description":"The copy's slug, unique within the organization; omit for the first free `-copy`, `-copy-2`… of the original's. Slugs are lowercase letters and digits in words separated by single hyphens, like forklift-operator."}}},"UpdateCertificationTypeRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null to clear the description."},"description_markdown":{"type":["string","null"],"maxLength":50000,"description":"Send null to clear the long-form markdown description."},"awarding_mode":{"type":"string","enum":["automatic","approval"],"description":"Changing the mode does not touch existing requests: pending requests on a now-automatic type remain decidable and withdrawable."}}},"CreateCertificationRuleRequest":{"type":"object","properties":{"rule_kind":{"type":"string","enum":["subject_hours","subject_record_count","evidence_document","manual_sign_off","quiz_pass","external_course_pass","scorm_course_complete","job_aid_viewed"],"description":"subject_hours: evidenced hours on the type's subjects (or on subject_matter_id alone) must reach threshold_hours. subject_record_count: completed records on the subjects (or on subject_matter_id alone) must reach threshold_count. evidence_document: a completed record on the subject must carry evidence of evidence_type (a pass_fail row counts only when it records a pass). manual_sign_off: a permission holder must record a sign-off for the rule. quiz_pass: the user must have a passing attempt of the quiz named by quiz_id. external_course_pass: the user must have a passing grade of the external LMS course named by external_course_id. scorm_course_complete: the user must have completed the hosted SCORM course named by scorm_course_id. job_aid_viewed: the user must have opened the job aid named by job_aid_id."},"label":{"type":"string","minLength":1,"maxLength":200,"description":"Human label shown wherever the rule's progress is, e.g. \"20 simulator hours\"."},"threshold_hours":{"type":"number","exclusiveMinimum":0,"description":"Hours required, for subject_hours rules. Counted from quantity evidence in hours or minutes, plus the elapsed time of date_range evidence.","example":20},"threshold_count":{"type":"integer","exclusiveMinimum":0,"description":"Completed records required, for subject_record_count rules.","example":3},"evidence_type":{"type":"string","enum":["score","date_range","quantity","url","file","pass_fail","note"],"description":"Evidence type that must be present, for evidence_document rules. A pass_fail rule is met only by a row recording a pass."},"quiz_id":{"type":"string","format":"uuid","description":"Quiz that must be passed, for quiz_pass rules. A quiz of the type's own organization."},"external_course_id":{"type":"string","format":"uuid","description":"Catalogue id of the external LMS course that must be passed, for external_course_pass rules. A course of the type's own organization. Passing is decided by the course: its passing_score when set, the imported passed flag otherwise."},"scorm_course_id":{"type":"string","format":"uuid","description":"Hosted SCORM course that must be completed, for scorm_course_complete rules. A course of the type's own organization. Completion is what the course's completion records count: an attempt the module reported complete without an explicit failed verdict."},"job_aid_id":{"type":"string","format":"uuid","description":"Job aid that must be viewed, for job_aid_viewed rules. An aid of the type's own organization. Viewing is an open of the aid's page (POST /job-aids/{job_aid_id}/views, which the page does on every load), inside the current recertification window; opens of its individual files do not count."},"subject_matter_id":{"type":"string","format":"uuid","description":"Narrows a subject_hours or subject_record_count rule to one subject: only records filed under it count. Must be one of the type's own subject tags; omit to count records on every tag of the type."}},"required":["rule_kind"]},"UpdateCertificationRuleRequest":{"type":"object","properties":{"label":{"type":["string","null"],"minLength":1,"maxLength":200,"description":"Send null to clear the label."},"threshold_hours":{"type":"number","exclusiveMinimum":0,"description":"Hours required, for subject_hours rules. Counted from quantity evidence in hours or minutes, plus the elapsed time of date_range evidence.","example":20},"threshold_count":{"type":"integer","exclusiveMinimum":0,"description":"Completed records required, for subject_record_count rules.","example":3},"evidence_type":{"type":"string","enum":["score","date_range","quantity","url","file","pass_fail","note"],"description":"Evidence type that must be present, for evidence_document rules. A pass_fail rule is met only by a row recording a pass."},"quiz_id":{"type":"string","format":"uuid","description":"Quiz that must be passed, for quiz_pass rules. A quiz of the type's own organization."},"external_course_id":{"type":"string","format":"uuid","description":"Catalogue id of the external LMS course that must be passed, for external_course_pass rules. A course of the type's own organization. Passing is decided by the course: its passing_score when set, the imported passed flag otherwise."},"scorm_course_id":{"type":"string","format":"uuid","description":"Hosted SCORM course that must be completed, for scorm_course_complete rules. A course of the type's own organization. Completion is what the course's completion records count: an attempt the module reported complete without an explicit failed verdict."},"job_aid_id":{"type":"string","format":"uuid","description":"Job aid that must be viewed, for job_aid_viewed rules. An aid of the type's own organization. Viewing is an open of the aid's page (POST /job-aids/{job_aid_id}/views, which the page does on every load), inside the current recertification window; opens of its individual files do not count."},"subject_matter_id":{"type":["string","null"],"format":"uuid","description":"Narrows a threshold rule to one of the type's subject tags; send null to count every tag again."}}},"RoleRequiredCertificationList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequiredCertification"}}},"required":["items"]},"RoleRequiredCertification":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"role_id":{"type":"string","format":"uuid"},"department_id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"certification_type_id":{"type":"string","format":"uuid"},"recert_scheme":{"type":"string","enum":["none","anniversary","fixed_calendar"],"description":"none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year."},"recert_interval_months":{"type":["number","null"]},"recert_anchor_month":{"type":["number","null"]},"recert_anchor_day":{"type":["number","null"]},"initial_due_at":{"type":["string","null"],"format":"date-time","description":"When holders' *initial* certification falls due; null when the role sets no deadline. Per-person due dates override it."},"created_by":{"type":["string","null"],"format":"uuid"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"certification_type":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"awarding_mode":{"type":"string","enum":["automatic","approval"],"description":"automatic: the user claims the certification themselves once every rule is satisfied. approval: the user requests it and a permission holder decides."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."}},"required":["id","slug","name","awarding_mode","archived_at"],"description":"The linked certification type, for display."}},"required":["id","role_id","department_id","organization_id","certification_type_id","recert_scheme","recert_interval_months","recert_anchor_month","recert_anchor_day","initial_due_at","created_by","created_at","certification_type"]},"CreateRoleRequiredCertificationRequest":{"type":"object","properties":{"certification_type_id":{"type":"string","format":"uuid","description":"A certification type of the same organization as the role's department."},"recert_scheme":{"type":"string","enum":["none","anniversary","fixed_calendar"],"description":"none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year."},"recert_interval_months":{"type":"integer","exclusiveMinimum":0,"description":"Months between recertifications, for the anniversary scheme.","example":12},"recert_anchor_month":{"type":"integer","minimum":1,"maximum":12,"description":"Calendar month (1-12) the certification falls due, for fixed_calendar.","example":3},"recert_anchor_day":{"type":"integer","minimum":1,"maximum":31,"description":"Day of the anchor month (1-31, clamped to the month's length), for fixed_calendar.","example":31},"initial_due_at":{"type":"string","format":"date-time","description":"When the *initial* certification falls due for every holder of the role. A member's per-person due date overrides it; omit to set no role-level deadline."}},"required":["certification_type_id","recert_scheme"]},"UpdateRoleRequiredCertificationRequest":{"type":"object","properties":{"recert_scheme":{"type":"string","enum":["none","anniversary","fixed_calendar"],"description":"none: certify once, never again. anniversary: due recert_interval_months after each award. fixed_calendar: due on recert_anchor_month/recert_anchor_day each year."},"recert_interval_months":{"type":"integer","exclusiveMinimum":0,"description":"Months between recertifications, for the anniversary scheme.","example":12},"recert_anchor_month":{"type":"integer","minimum":1,"maximum":12,"description":"Calendar month (1-12) the certification falls due, for fixed_calendar.","example":3},"recert_anchor_day":{"type":"integer","minimum":1,"maximum":31,"description":"Day of the anchor month (1-31, clamped to the month's length), for fixed_calendar.","example":31},"initial_due_at":{"type":"string","format":"date-time","description":"When the *initial* certification falls due for every holder of the role. A member's per-person due date overrides it; omit to set no role-level deadline."}},"required":["recert_scheme"]},"RoleCertificationCompletion":{"type":"object","properties":{"role":{"$ref":"#/components/schemas/TeamRoleRef"},"trainee_count":{"type":"integer","description":"Holders of the role whose grant has not expired."},"fully_complete_count":{"type":"integer","description":"Trainees whose every required type is complete."},"fully_complete_percent":{"type":["number","null"],"description":"fully_complete_count over trainee_count, 0–100; null with no trainees."},"certifications":{"type":"array","items":{"$ref":"#/components/schemas/RoleRequiredCertificationStat"},"description":"One tally per required certification type, archived types left out. Empty when the role requires nothing — every trainee is then vacuously complete."},"trainees":{"type":"array","items":{"$ref":"#/components/schemas/TraineeCompletion"},"description":"The roster, named members first, each with their per-type standing."}},"required":["role","trainee_count","fully_complete_count","fully_complete_percent","certifications","trainees"]},"TeamRoleRef":{"type":"object","properties":{"role_id":{"type":"string","format":"uuid"},"role_slug":{"type":"string"},"role_name":{"type":"string"},"department_id":{"type":"string","format":"uuid"},"department_slug":{"type":"string"},"department_name":{"type":"string"},"organization_id":{"type":"string","format":"uuid"},"organization_slug":{"type":"string"},"organization_name":{"type":"string"}},"required":["role_id","role_slug","role_name","department_id","department_slug","department_name","organization_id","organization_slug","organization_name"]},"RoleRequiredCertificationStat":{"type":"object","properties":{"certification_type":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"awarding_mode":{"type":"string","enum":["automatic","approval"]}},"required":["id","slug","name","awarding_mode"]},"complete_count":{"type":"integer"},"overdue_count":{"type":"integer"},"not_certified_count":{"type":"integer"},"complete_percent":{"type":["number","null"],"description":"complete_count over the trainee count, 0–100; null with no trainees."}},"required":["certification_type","complete_count","overdue_count","not_certified_count","complete_percent"]},"TraineeCompletion":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid"},"display_name":{"type":["string","null"],"description":"The trainee's profile name from users; null when unset."},"email":{"type":["string","null"]},"fully_complete":{"type":"boolean","description":"Every required type of the role is complete; vacuously true with none."},"requirements":{"type":"array","items":{"$ref":"#/components/schemas/TraineeRequirementStatus"},"description":"In the same order as the completion's certifications."}},"required":["user_id","display_name","email","fully_complete","requirements"]},"TraineeRequirementStatus":{"type":"object","properties":{"certification_type_id":{"type":"string","format":"uuid"},"state":{"type":"string","enum":["complete","overdue","not_certified"],"description":"complete: current award and not past due. overdue: past due, lapsed or never certified. not_certified: no award, no due date passed."},"due_at":{"type":["string","null"],"format":"date-time","description":"When the certification (or next recertification) falls due, if a date applies."},"awarded_at":{"type":["string","null"],"format":"date-time","description":"When the current award was made; null while uncertified."}},"required":["certification_type_id","state","due_at","awarded_at"]},"TeamCertificationCompletionList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RoleCertificationCompletion"},"description":"Every role in the caller's managed subtree that requires at least one live certification type, fully tallied."}},"required":["items"]},"QuizQuestionList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/QuizQuestion"}}},"required":["items"]},"QuizQuestion":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"question_bank_id":{"type":"string","format":"uuid"},"kind":{"type":"string","enum":["true_false","multiple_choice","multi_select","ordering","short_answer","numeric","matching","fill_in_blank","long_answer","drawing"],"description":"true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. fill_in_blank: the prompt's runs of three or more underscores are blanks, and the options are the accepted answers, each naming its blank_index. long_answer: a free-text essay answer with no accepted spellings, always graded by hand. drawing: the taker draws on a canvas, over drawing_starting_image when one is attached, always graded by hand."},"prompt":{"type":"string"},"prompt_format":{"type":"string","enum":["plaintext","markdown","pdf"],"description":"plaintext: the prompt renders verbatim. markdown: the same prompt text renders as Markdown. pdf: an uploaded PDF carries the question; the prompt text is the instruction shown above it."},"prompt_pdf_file_name":{"type":["string","null"],"description":"Original name of the attached PDF, for pdf prompts; null otherwise. The file itself is fetched through the question's pdf endpoint."},"prompt_pdf_prefer_inline_display":{"type":"boolean","description":"Whether the attached PDF is drawn beside the question or offered as a download only (`00051`). A one-page scenario belongs on screen; a long reference the taker works from on paper only buries the answers beneath it. Ignored on prompts with no PDF."},"explanation":{"type":["string","null"],"description":"Shown in answer review when the quiz reveals correct answers."},"points":{"type":"integer","minimum":1,"maximum":1000,"description":"What a full-marks answer is worth, 1 to 1000; an attempt's score is the points earned as a percentage of the points presented.","example":1},"requires_manual_grading":{"type":"boolean","description":"Whether a marker awards the points by hand. Forced on for long_answer and drawing questions. A machine-gradable kind flagged this way is still checked at submit, and the verdict shown to the marker as a suggestion; nothing counts until the marker records points."},"rubric_markdown":{"type":["string","null"],"maxLength":50000,"description":"The marking scheme, as markdown: shown to markers on the grading page, and to takers only when rubric_visible_to_takers. Rendered through the sanitized pipeline question prompts use.","example":"- 2 points: names both hazards\n- 1 point: names one\n- 0: neither"},"rubric_visible_to_takers":{"type":"boolean","description":"Whether takers see the rubric too — beneath the prompt while answering, and again in answer review. Off, the rubric reaches markers only."},"drawing_starting_image":{"type":["string","null"],"maxLength":3000000,"pattern":"^data:image\\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$","description":"The image a drawing question's taker draws over, as a data URL; null for a blank canvas, and on every other kind.","example":"data:image/png;base64,iVBORw0KGgo…"},"correct_boolean":{"type":["boolean","null"],"description":"The answer of a true_false question; null for every other kind."},"correct_number":{"type":["number","null"],"description":"The answer of a numeric question; null for every other kind."},"tolerance":{"type":["number","null"],"description":"How far a numeric response may stray and still count; null otherwise."},"position":{"type":"integer"},"options":{"type":"array","items":{"$ref":"#/components/schemas/QuizQuestionOption"},"description":"Choices, ordering items (authored order = correct order), accepted short answers, matching pairs or the accepted answers of each blank, in authored order; empty for true_false, numeric, long_answer and drawing questions."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","question_bank_id","kind","prompt","prompt_format","prompt_pdf_file_name","prompt_pdf_prefer_inline_display","explanation","points","requires_manual_grading","rubric_markdown","rubric_visible_to_takers","drawing_starting_image","correct_boolean","correct_number","tolerance","position","options","archived_at","created_at"]},"QuizQuestionOption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string"},"is_correct":{"type":"boolean","description":"Meaningful on choice questions; always true on accepted short answers and false on ordering items and matching pairs."},"match_label":{"type":["string","null"],"description":"The right-hand side of a matching pair; null on every other kind."},"blank_index":{"type":["integer","null"],"description":"Which blank of a fill_in_blank prompt this accepted answer fills, counting from 0 left to right; null on every other kind."},"position":{"type":"integer"}},"required":["id","label","is_correct","match_label","blank_index","position"]},"ReorderBankQuestionsRequest":{"type":"object","properties":{"question_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":1000,"description":"Every question of the bank — archived ones included, since they keep their slot — in the order to present them; first is position 0."}},"required":["question_ids"]},"CreateQuizQuestionRequest":{"type":"object","properties":{"kind":{"type":"string","enum":["true_false","multiple_choice","multi_select","ordering","short_answer","numeric","matching","fill_in_blank","long_answer","drawing"],"description":"true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. fill_in_blank: the prompt's runs of three or more underscores are blanks, and the options are the accepted answers, each naming its blank_index. long_answer: a free-text essay answer with no accepted spellings, always graded by hand. drawing: the taker draws on a canvas, over drawing_starting_image when one is attached, always graded by hand."},"prompt":{"type":"string","minLength":1,"maxLength":2000,"description":"On fill_in_blank questions, every run of three or more underscores (___) is one blank, up to 10 per prompt.","example":"Fire doors may be propped open during deliveries."},"prompt_format":{"type":"string","enum":["plaintext","markdown","pdf"],"description":"Defaults to plaintext."},"prompt_pdf":{"$ref":"#/components/schemas/QuestionPromptPdf"},"prompt_pdf_prefer_inline_display":{"type":"boolean","description":"Omit to draw the PDF beside the question. Send false for a long reference the taker downloads and works from instead, so it does not bury the answers. Remembered across replacements of the file."},"explanation":{"type":"string","maxLength":2000,"description":"Omit or send empty for no explanation."},"points":{"type":"integer","minimum":1,"maximum":1000,"description":"Defaults to 1.","example":1},"requires_manual_grading":{"type":"boolean","description":"Defaults to false, except for long_answer and drawing questions, which are always manual (sending false for one is refused)."},"rubric_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no rubric.","example":"- 2 points: names both hazards\n- 1 point: names one\n- 0: neither"},"rubric_visible_to_takers":{"type":"boolean","description":"Defaults to false: the rubric reaches markers only."},"correct_boolean":{"type":"boolean","description":"Required for true_false questions; omit for every other kind."},"correct_number":{"type":"number","description":"numeric questions only.","example":42},"tolerance":{"type":"number","minimum":0,"description":"numeric questions only; 0 demands the exact value.","example":0.5},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":500},"is_correct":{"type":"boolean","description":"Choice kinds only; ignored (and stored per kind) on the others."},"match_label":{"type":"string","minLength":1,"maxLength":500,"description":"Required on every option of a matching question; omit elsewhere."},"blank_index":{"type":"integer","minimum":0,"maximum":9,"description":"Required on every option of a fill_in_blank question: the blank this accepted answer fills, counting the prompt's blanks from 0 left to right. Omit elsewhere."}},"required":["label"]},"minItems":1,"maxItems":50,"description":"Required for every kind with options; omit for true_false, numeric, long_answer and drawing."},"drawing_starting_image":{"type":["string","null"],"maxLength":3000000,"pattern":"^data:image\\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$","description":"drawing questions only: the image the taker draws over, as a base64 data URL. Omit (or send null) for a blank canvas.","example":"data:image/png;base64,iVBORw0KGgo…"}},"required":["kind","prompt"]},"QuestionPromptPdf":{"type":"object","properties":{"pathname":{"type":"string","minLength":1,"description":"The pathname issued by the bank's question-pdfs endpoint, after the PUT."},"file_name":{"type":"string","minLength":1,"maxLength":300}},"required":["pathname","file_name"],"description":"Required for pdf prompts; omit for plaintext and markdown."},"QuestionPdfUploadTicket":{"type":"object","properties":{"pathname":{"type":"string","description":"Where the private blob will live; pass it as prompt_pdf.pathname when creating or updating the question. Not itself a URL."},"upload_url":{"type":"string","description":"PUT the file bytes here, with content type application/pdf."},"expires_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["pathname","upload_url","expires_at"]},"CreateQuestionPdfUploadRequest":{"type":"object","properties":{"file_name":{"type":"string","minLength":1,"maxLength":300},"size_bytes":{"type":"integer","exclusiveMinimum":0,"maximum":26214400}},"required":["file_name","size_bytes"]},"QuizQuestionBankList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/QuizQuestionBank"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"QuizQuestionBank":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the bank."},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"question_count":{"type":"integer","description":"Unarchived questions currently in the bank."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","owner_organization_id","owner_department_id","slug","name","description","question_count","archived_at","created_at"]},"CreateQuizQuestionBankRequest":{"type":"object","properties":{"owner_organization_id":{"type":"string","format":"uuid","description":"Organization the bank belongs to. Always required."},"owner_department_id":{"type":"string","format":"uuid","description":"Omit for a bank owned by the organization directly."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"fire-safety-basics","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like fire-safety-basics."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Fire safety basics"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."}},"required":["owner_organization_id","slug","name"]},"UpdateQuizQuestionBankRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null to clear the description."},"archived":{"type":"boolean","description":"True archives the bank — it stops feeding new attempts through any quiz link — and false restores it. History is untouched either way."}}},"QuestionTargetBankRequest":{"type":"object","properties":{"question_bank_id":{"type":"string","format":"uuid","description":"The bank to copy or move the question into — one of the question's own organization. A copy may land in the question's own bank."}},"required":["question_bank_id"]},"UpdateQuizQuestionRequest":{"type":"object","properties":{"prompt":{"type":"string","minLength":1,"maxLength":2000,"description":"On fill_in_blank questions the new prompt's blanks must still be covered by the options — send both when adding or removing a blank."},"prompt_format":{"type":"string","enum":["plaintext","markdown","pdf"],"description":"Switching between plaintext and markdown re-renders the same prompt text. Switching to pdf needs prompt_pdf (unless one is already attached); switching away discards the attachment."},"prompt_pdf":{"allOf":[{"$ref":"#/components/schemas/QuestionPromptPdf"},{"description":"Attaches (or replaces) the question's PDF; pdf prompts only."}]},"prompt_pdf_prefer_inline_display":{"type":"boolean","description":"Omit to draw the PDF beside the question. Send false for a long reference the taker downloads and works from instead, so it does not bury the answers. Remembered across replacements of the file."},"explanation":{"type":["string","null"],"maxLength":2000,"description":"Send null to clear the explanation."},"points":{"type":"integer","minimum":1,"maximum":1000,"description":"Applies to papers built from now on; attempts already started keep the points they were built with.","example":1},"requires_manual_grading":{"type":"boolean","description":"Applies to papers built from now on. Cannot be switched off on a long_answer or drawing question."},"rubric_markdown":{"type":["string","null"],"maxLength":50000,"description":"Send null or empty to clear the rubric.","example":"- 2 points: names both hazards\n- 1 point: names one\n- 0: neither"},"rubric_visible_to_takers":{"type":"boolean","description":"Whether takers see the rubric too — beneath the prompt while answering, and again in answer review. Off, the rubric reaches markers only."},"correct_boolean":{"type":"boolean","description":"For true_false questions only."},"correct_number":{"type":"number","description":"numeric questions only.","example":42},"tolerance":{"type":"number","minimum":0,"description":"numeric questions only; 0 demands the exact value.","example":0.5},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":500},"is_correct":{"type":"boolean","description":"Choice kinds only; ignored (and stored per kind) on the others."},"match_label":{"type":"string","minLength":1,"maxLength":500,"description":"Required on every option of a matching question; omit elsewhere."},"blank_index":{"type":"integer","minimum":0,"maximum":9,"description":"Required on every option of a fill_in_blank question: the blank this accepted answer fills, counting the prompt's blanks from 0 left to right. Omit elsewhere."}},"required":["label"]},"minItems":1,"maxItems":50,"description":"For questions with options only. Replaces the whole option set; answer review of attempts that saw the old options degrades accordingly. Grading of submitted attempts never changes."},"drawing_starting_image":{"type":["string","null"],"maxLength":3000000,"pattern":"^data:image\\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$","description":"drawing questions only: replaces the starting image; send null to clear it. Papers already started keep the canvas they opened with.","example":"data:image/png;base64,iVBORw0KGgo…"}}},"AssignedQuizList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AssignedQuiz"}}},"required":["items"]},"AssignedQuiz":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"footer_markdown":{"type":["string","null"],"description":"Markdown the sitting renders beneath its last question, above the submit button; null shows nothing."},"organization_id":{"type":"string","format":"uuid"},"organization_name":{"type":"string"},"delivery_kind":{"type":"string","enum":["live","async"],"description":"live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply."},"time_limit_seconds":{"type":["integer","null"]},"pass_percentage":{"type":"number"},"max_attempts":{"type":["integer","null"]},"show_correct_answers":{"type":"boolean"},"current_sitting":{"$ref":"#/components/schemas/ScheduledQuiz"},"next_sitting":{"allOf":[{"$ref":"#/components/schemas/ScheduledQuiz"},{"description":"The next sitting yet to open, for live quizzes; null otherwise."}]},"state":{"type":"string","enum":["upcoming","open","closed"],"description":"Takeability right now: async published quizzes are open; live ones are open inside a sitting, upcoming while one is yet to open, closed otherwise."},"attempts_used":{"type":"integer","description":"Attempts counted against max_attempts: within the current sitting for live quizzes (0 when none is open), lifetime for async ones."},"in_progress_attempt_id":{"type":["string","null"],"format":"uuid"},"latest_attempt":{"$ref":"#/components/schemas/QuizAttempt"},"has_passed":{"type":["boolean","null"],"description":"Whether any attempt passed; null while no attempt has been submitted, results are withheld, or an attempt still awaits grading."}},"required":["id","slug","name","description","footer_markdown","organization_id","organization_name","delivery_kind","time_limit_seconds","pass_percentage","max_attempts","show_correct_answers","current_sitting","next_sitting","state","attempts_used","in_progress_attempt_id","latest_attempt","has_passed"]},"ScheduledQuiz":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"quiz_id":{"type":"string","format":"uuid"},"label":{"type":["string","null"],"description":"Human label for the sitting, e.g. \"2026 annual recertification\"."},"opens_at":{"type":"string","format":"date-time"},"closes_at":{"type":"string","format":"date-time"},"audience":{"type":"string","enum":["assignees","roster"],"description":"assignees: open to everyone the quiz is assigned to. roster: open only to the trainees on this sitting's roster (see its participants), who must still be assignees of the quiz; a roster sitting with nobody on it is takeable by nobody."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","quiz_id","label","opens_at","closes_at","audience","created_at"],"description":"The sitting open right now, for live quizzes; null otherwise."},"QuizAttempt":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"quiz_id":{"type":"string","format":"uuid"},"scheduled_quiz_id":{"type":["string","null"],"format":"uuid","description":"The sitting a live attempt was sat in; null on async attempts."},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"],"description":"Display name of the taker's user account, when the read joins it; else null."},"attempt_number":{"type":"integer"},"status":{"type":"string","enum":["in_progress","pending_grading","submitted"],"description":"in_progress while being sat; pending_grading once submitted with a manually graded question still to be marked or finalized; submitted once scored."},"started_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"deadline_at":{"type":["string","null"],"format":"date-time","description":"When the attempt auto-submits as-is: started_at plus the time limit, capped by the sitting's close. Null when neither applies."},"submitted_at":{"type":["string","null"],"format":"date-time"},"auto_submitted":{"type":"boolean","description":"True when the deadline finalized the attempt rather than the taker."},"question_count":{"type":"integer"},"correct_count":{"type":["integer","null"],"description":"Questions awarded full marks; null until scored."},"points_possible":{"type":"integer","description":"The points of every presented question, summed when the paper was built."},"points_awarded":{"type":["integer","null"],"description":"Points earned across the paper; null until scored or while withheld."},"score_percentage":{"type":["number","null"],"description":"points_awarded as a percentage of points_possible, to two decimals."},"passed":{"type":["boolean","null"]},"requires_manual_grading":{"type":"boolean","description":"Whether the paper holds a manually graded question: such an attempt waits in pending_grading for a marker, and its results reach the taker only once released."},"finalized_at":{"type":["string","null"],"format":"date-time","description":"When a marker finalized the grading of a manually graded attempt."},"results_released_at":{"type":["string","null"],"format":"date-time","description":"The per-attempt release stamp; null until an administrator releases it."},"results_visible":{"type":"boolean","description":"Whether the caller may see this attempt's results. False nulls correct_count, points_awarded, score_percentage and passed."}},"required":["id","quiz_id","scheduled_quiz_id","user_id","user_display_name","attempt_number","status","started_at","deadline_at","submitted_at","auto_submitted","question_count","correct_count","points_possible","points_awarded","score_percentage","passed","requires_manual_grading","finalized_at","results_released_at","results_visible"]},"QuizAttemptDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"quiz_id":{"type":"string","format":"uuid"},"scheduled_quiz_id":{"type":["string","null"],"format":"uuid","description":"The sitting a live attempt was sat in; null on async attempts."},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"],"description":"Display name of the taker's user account, when the read joins it; else null."},"attempt_number":{"type":"integer"},"status":{"type":"string","enum":["in_progress","pending_grading","submitted"],"description":"in_progress while being sat; pending_grading once submitted with a manually graded question still to be marked or finalized; submitted once scored."},"started_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"deadline_at":{"type":["string","null"],"format":"date-time","description":"When the attempt auto-submits as-is: started_at plus the time limit, capped by the sitting's close. Null when neither applies."},"submitted_at":{"type":["string","null"],"format":"date-time"},"auto_submitted":{"type":"boolean","description":"True when the deadline finalized the attempt rather than the taker."},"question_count":{"type":"integer"},"correct_count":{"type":["integer","null"],"description":"Questions awarded full marks; null until scored."},"points_possible":{"type":"integer","description":"The points of every presented question, summed when the paper was built."},"points_awarded":{"type":["integer","null"],"description":"Points earned across the paper; null until scored or while withheld."},"score_percentage":{"type":["number","null"],"description":"points_awarded as a percentage of points_possible, to two decimals."},"passed":{"type":["boolean","null"]},"requires_manual_grading":{"type":"boolean","description":"Whether the paper holds a manually graded question: such an attempt waits in pending_grading for a marker, and its results reach the taker only once released."},"finalized_at":{"type":["string","null"],"format":"date-time","description":"When a marker finalized the grading of a manually graded attempt."},"results_released_at":{"type":["string","null"],"format":"date-time","description":"The per-attempt release stamp; null until an administrator releases it."},"results_visible":{"type":"boolean","description":"Whether the caller may see this attempt's results. False nulls correct_count, points_awarded, score_percentage and passed."},"questions":{"type":"array","items":{"$ref":"#/components/schemas/TakerQuestion"},"description":"The attempt's paper in presentation order, answers stripped."}},"required":["id","quiz_id","scheduled_quiz_id","user_id","user_display_name","attempt_number","status","started_at","deadline_at","submitted_at","auto_submitted","question_count","correct_count","points_possible","points_awarded","score_percentage","passed","requires_manual_grading","finalized_at","results_released_at","results_visible","questions"]},"TakerQuestion":{"type":"object","properties":{"question_id":{"type":"string","format":"uuid"},"position":{"type":"integer"},"kind":{"type":"string","enum":["true_false","multiple_choice","multi_select","ordering","short_answer","numeric","matching","fill_in_blank","long_answer","drawing"],"description":"true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. fill_in_blank: the prompt's runs of three or more underscores are blanks, and the options are the accepted answers, each naming its blank_index. long_answer: a free-text essay answer with no accepted spellings, always graded by hand. drawing: the taker draws on a canvas, over drawing_starting_image when one is attached, always graded by hand."},"prompt":{"type":"string"},"prompt_format":{"type":"string","enum":["plaintext","markdown","pdf"],"description":"plaintext: the prompt renders verbatim. markdown: the same prompt text renders as Markdown. pdf: an uploaded PDF carries the question; the prompt text is the instruction shown above it."},"prompt_pdf_file_name":{"type":["string","null"],"description":"Original name of the attached PDF, for pdf prompts; null otherwise. Fetch the file through the attempt's question pdf endpoint."},"prompt_pdf_prefer_inline_display":{"type":"boolean","description":"Whether the attached PDF is drawn beside the question or offered as a download only (`00051`). A one-page scenario belongs on screen; a long reference the taker works from on paper only buries the answers beneath it. Ignored on prompts with no PDF."},"points":{"type":"integer","minimum":1,"maximum":1000,"description":"What this question is worth on this paper, as snapshotted when it was built.","example":1},"manually_graded":{"type":"boolean","description":"Whether a marker will award this question's points by hand — the attempt's score waits for them."},"rubric_markdown":{"type":["string","null"],"maxLength":50000,"description":"The marking scheme, when its author chose to show it to takers; null otherwise.","example":"- 2 points: names both hazards\n- 1 point: names one\n- 0: neither"},"options":{"type":"array","items":{"$ref":"#/components/schemas/TakerQuestionOption"},"description":"Choices, ordering items or the left column of a matching question, in the order this attempt presents them — correctness stripped. Empty for true_false, short_answer, numeric, fill_in_blank, long_answer and drawing questions."},"match_options":{"type":"array","items":{"$ref":"#/components/schemas/TakerQuestionOption"},"description":"The right column of a matching question in its own shuffled order, each entry identified by the option whose match_label it is. Empty for every other kind."},"drawing_starting_image":{"type":["string","null"],"maxLength":3000000,"pattern":"^data:image\\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$","description":"drawing questions: the image the taker draws over, as a data URL; null for a blank canvas, and on every other kind.","example":"data:image/png;base64,iVBORw0KGgo…"},"response_boolean":{"type":["boolean","null"]},"selected_option_ids":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"description":"Chosen options of a choice question, or every item of an ordering question in the taker's arrangement."},"response_text":{"type":["string","null"],"description":"A short_answer or long_answer response."},"response_number":{"type":["number","null"],"description":"A numeric response."},"response_matches":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ResponseMatch"},"description":"The pairs made so far on a matching question."},"response_blanks":{"type":["array","null"],"items":{"type":"string"},"description":"A fill_in_blank response: one string per blank in prompt order, empty where the taker left a blank unfilled."},"response_drawing":{"type":["string","null"],"maxLength":4000000,"pattern":"^data:image\\/(png|webp|jpeg);base64,[A-Za-z0-9+/]+=*$","description":"A drawing response: the taker's canvas as one image data URL, the starting image beneath their strokes."},"answered_at":{"type":["string","null"],"format":"date-time"}},"required":["question_id","position","kind","prompt","prompt_format","prompt_pdf_file_name","prompt_pdf_prefer_inline_display","points","manually_graded","rubric_markdown","options","match_options","drawing_starting_image","response_boolean","selected_option_ids","response_text","response_number","response_matches","response_blanks","response_drawing","answered_at"]},"TakerQuestionOption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string"}},"required":["id","label"]},"ResponseMatch":{"type":"object","properties":{"option_id":{"type":"string","format":"uuid","description":"An entry of the question's options."},"match_option_id":{"type":"string","format":"uuid","description":"An entry of the question's match_options — the right-hand label chosen."}},"required":["option_id","match_option_id"]},"QuizAttemptReview":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"quiz_id":{"type":"string","format":"uuid"},"scheduled_quiz_id":{"type":["string","null"],"format":"uuid","description":"The sitting a live attempt was sat in; null on async attempts."},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"],"description":"Display name of the taker's user account, when the read joins it; else null."},"attempt_number":{"type":"integer"},"status":{"type":"string","enum":["in_progress","pending_grading","submitted"],"description":"in_progress while being sat; pending_grading once submitted with a manually graded question still to be marked or finalized; submitted once scored."},"started_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"deadline_at":{"type":["string","null"],"format":"date-time","description":"When the attempt auto-submits as-is: started_at plus the time limit, capped by the sitting's close. Null when neither applies."},"submitted_at":{"type":["string","null"],"format":"date-time"},"auto_submitted":{"type":"boolean","description":"True when the deadline finalized the attempt rather than the taker."},"question_count":{"type":"integer"},"correct_count":{"type":["integer","null"],"description":"Questions awarded full marks; null until scored."},"points_possible":{"type":"integer","description":"The points of every presented question, summed when the paper was built."},"points_awarded":{"type":["integer","null"],"description":"Points earned across the paper; null until scored or while withheld."},"score_percentage":{"type":["number","null"],"description":"points_awarded as a percentage of points_possible, to two decimals."},"passed":{"type":["boolean","null"]},"requires_manual_grading":{"type":"boolean","description":"Whether the paper holds a manually graded question: such an attempt waits in pending_grading for a marker, and its results reach the taker only once released."},"finalized_at":{"type":["string","null"],"format":"date-time","description":"When a marker finalized the grading of a manually graded attempt."},"results_released_at":{"type":["string","null"],"format":"date-time","description":"The per-attempt release stamp; null until an administrator releases it."},"results_visible":{"type":"boolean","description":"Whether the caller may see this attempt's results. False nulls correct_count, points_awarded, score_percentage and passed."},"questions":{"type":"array","items":{"$ref":"#/components/schemas/QuizReviewQuestion"}}},"required":["id","quiz_id","scheduled_quiz_id","user_id","user_display_name","attempt_number","status","started_at","deadline_at","submitted_at","auto_submitted","question_count","correct_count","points_possible","points_awarded","score_percentage","passed","requires_manual_grading","finalized_at","results_released_at","results_visible","questions"]},"QuizReviewQuestion":{"type":"object","properties":{"question_id":{"type":"string","format":"uuid"},"position":{"type":"integer"},"kind":{"type":"string","enum":["true_false","multiple_choice","multi_select","ordering","short_answer","numeric","matching","fill_in_blank","long_answer","drawing"],"description":"true_false: answered true or false. multiple_choice: exactly one correct option. multi_select: several options may be correct, scored all-or-nothing. ordering: the options are items whose authored order is the answer. short_answer: the options are the accepted spellings of a typed answer. numeric: a number within a tolerance of correct_number. matching: each option is a label/match_label pair the taker reunites. fill_in_blank: the prompt's runs of three or more underscores are blanks, and the options are the accepted answers, each naming its blank_index. long_answer: a free-text essay answer with no accepted spellings, always graded by hand. drawing: the taker draws on a canvas, over drawing_starting_image when one is attached, always graded by hand."},"prompt":{"type":"string"},"prompt_format":{"type":"string","enum":["plaintext","markdown","pdf"],"description":"plaintext: the prompt renders verbatim. markdown: the same prompt text renders as Markdown. pdf: an uploaded PDF carries the question; the prompt text is the instruction shown above it."},"prompt_pdf_file_name":{"type":["string","null"]},"prompt_pdf_prefer_inline_display":{"type":"boolean","description":"Whether the attached PDF is drawn beside the question or offered as a download only (`00051`). A one-page scenario belongs on screen; a long reference the taker works from on paper only buries the answers beneath it. Ignored on prompts with no PDF."},"explanation":{"type":["string","null"]},"correct_boolean":{"type":["boolean","null"]},"correct_number":{"type":["number","null"]},"tolerance":{"type":["number","null"]},"options":{"type":"array","items":{"$ref":"#/components/schemas/ReviewQuestionOption"},"description":"In presentation order for choice and matching questions; in *authored* (correct) order for ordering questions; the accepted answers for short_answer, and for fill_in_blank with each one's blank_index."},"drawing_starting_image":{"type":["string","null"],"maxLength":3000000,"pattern":"^data:image\\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+=*$","description":"drawing questions: the image the taker draws over, as a data URL; null for a blank canvas, and on every other kind.","example":"data:image/png;base64,iVBORw0KGgo…"},"response_boolean":{"type":["boolean","null"]},"selected_option_ids":{"type":["array","null"],"items":{"type":"string","format":"uuid"},"description":"Chosen options of a choice question, or every item of an ordering question in the taker's arrangement."},"response_text":{"type":["string","null"],"description":"A short_answer or long_answer response."},"response_number":{"type":["number","null"],"description":"A numeric response."},"response_matches":{"type":["array","null"],"items":{"$ref":"#/components/schemas/ResponseMatch"},"description":"The pairs made so far on a matching question."},"response_blanks":{"type":["array","null"],"items":{"type":"string"},"description":"A fill_in_blank response: one string per blank in prompt order, empty where the taker left a blank unfilled."},"response_drawing":{"type":["string","null"],"maxLength":4000000,"pattern":"^data:image\\/(png|webp|jpeg);base64,[A-Za-z0-9+/]+=*$","description":"A drawing response: the taker's canvas as one image data URL, the starting image beneath their strokes."},"answered_at":{"type":["string","null"],"format":"date-time","description":"When the taker last answered the question; null when they never did."},"is_correct":{"type":["boolean","null"],"description":"As graded at submit time; unanswered questions were graded incorrect. On a manually graded question, null until marked, then whether full marks were awarded."},"points_possible":{"type":"integer","description":"What the question was worth when the paper was built."},"points_awarded":{"type":["integer","null"],"description":"Points earned — the machine's at submit, or the marker's; null while ungraded."},"manually_graded":{"type":"boolean","description":"Whether a marker awards this question's points, per the paper's snapshot."},"auto_result":{"type":["boolean","null"],"description":"Markers only: on a manually graded machine-checkable question, the machine's verdict as a suggestion. Null for long answers, on unflagged questions, and for takers."},"rubric_markdown":{"type":["string","null"],"maxLength":50000,"description":"The marking scheme: always for markers and quizzes:read holders, and for the taker only when its author chose to show it.","example":"- 2 points: names both hazards\n- 1 point: names one\n- 0: neither"},"feedback_markdown":{"type":["string","null"],"description":"The marker's note to the taker, as markdown; null when none was left."},"graded_at":{"type":["string","null"],"format":"date-time","description":"When a marker last recorded points for this question; null otherwise."}},"required":["question_id","position","kind","prompt","prompt_format","prompt_pdf_file_name","prompt_pdf_prefer_inline_display","explanation","correct_boolean","correct_number","tolerance","options","drawing_starting_image","response_boolean","selected_option_ids","response_text","response_number","response_matches","response_blanks","response_drawing","answered_at","is_correct","points_possible","points_awarded","manually_graded","auto_result","rubric_markdown","feedback_markdown","graded_at"]},"ReviewQuestionOption":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"label":{"type":"string"},"is_correct":{"type":"boolean"},"match_label":{"type":["string","null"]},"blank_index":{"type":["integer","null"]}},"required":["id","label","is_correct","match_label","blank_index"]},"AnswerQuizQuestionRequest":{"type":"object","properties":{"question_id":{"type":"string","format":"uuid"},"response_boolean":{"type":"boolean"},"selected_option_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":20,"description":"multiple_choice: exactly one id. multi_select: the chosen ids. ordering: every presented item id, in the taker's arrangement."},"response_text":{"type":"string","minLength":1,"maxLength":20000,"description":"short_answer: up to 500 characters. long_answer: up to 20000."},"response_number":{"type":"number"},"response_matches":{"type":"array","items":{"$ref":"#/components/schemas/ResponseMatch"},"minItems":1,"maxItems":10},"response_blanks":{"type":"array","items":{"type":"string","maxLength":500},"minItems":1,"maxItems":10,"description":"fill_in_blank: one entry per blank of the prompt, in order; leave an entry empty for a blank not yet filled, but fill at least one."},"response_drawing":{"type":"string","maxLength":4000000,"pattern":"^data:image\\/(png|webp|jpeg);base64,[A-Za-z0-9+/]+=*$","description":"drawing: the taker's canvas as one image — the starting image, if any, beneath their strokes — as a base64 data URL (image/png, image/webp or image/jpeg)."}},"required":["question_id"]},"GradingQueueList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/GradingQueueItem"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"GradingQueueItem":{"type":"object","properties":{"attempt_id":{"type":"string","format":"uuid"},"quiz_id":{"type":"string","format":"uuid"},"quiz_name":{"type":"string"},"quiz_slug":{"type":"string"},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"]},"user_email":{"type":["string","null"]},"attempt_number":{"type":"integer"},"submitted_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"auto_submitted":{"type":"boolean"},"manual_count":{"type":"integer","description":"Manually graded questions on the paper."},"ungraded_count":{"type":"integer","description":"Of those, the ones still without points; 0 means ready to finalize."}},"required":["attempt_id","quiz_id","quiz_name","quiz_slug","user_id","user_display_name","user_email","attempt_number","submitted_at","auto_submitted","manual_count","ungraded_count"]},"GradeQuestionRequest":{"type":"object","properties":{"points_awarded":{"type":"integer","minimum":0,"description":"Whole points, 0 up to the question's points_possible on this paper. Full marks records the question as correct; anything less as incorrect.","example":3},"feedback_markdown":{"type":["string","null"],"maxLength":20000,"description":"A note to the taker, as markdown, shown beside the question in their answer review once the attempt's results are released. Omit to keep the current note; send null or empty to clear it."}},"required":["points_awarded"]},"ReleaseAttemptsResponse":{"type":"object","properties":{"released_count":{"type":"integer","description":"How many attempts this call stamped as released."},"released_ids":{"type":"array","items":{"type":"string","format":"uuid"}}},"required":["released_count","released_ids"]},"ReleaseAttemptsRequest":{"type":"object","properties":{"attempt_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":500,"description":"Attempts of this quiz to release. Ones already released, still in progress or still awaiting grading are skipped, not refused; ids of other quizzes are ignored."}},"required":["attempt_ids"]},"QuizAnswersExport":{"type":"object","properties":{"quiz_id":{"type":"string","format":"uuid"},"quiz_name":{"type":"string"},"exported_at":{"type":"string","format":"date-time"},"attempts":{"type":"array","items":{"$ref":"#/components/schemas/QuizAttemptReview"},"description":"Every exported attempt with its graded paper, newest first."}},"required":["quiz_id","quiz_name","exported_at","attempts"]},"QuizAttemptList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/QuizAttempt"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"CreateScheduledQuizRequest":{"type":"object","properties":{"label":{"type":"string","minLength":1,"maxLength":200,"description":"Omit or send empty for an unlabelled sitting.","example":"2026 annual recertification"},"opens_at":{"type":"string","format":"date-time"},"closes_at":{"type":"string","format":"date-time"},"audience":{"type":"string","enum":["assignees","roster"],"description":"Defaults to assignees. A roster sitting starts with an empty roster."}},"required":["opens_at","closes_at"]},"UpdateScheduledQuizRequest":{"type":"object","properties":{"label":{"type":["string","null"],"minLength":1,"maxLength":200,"description":"Send null to clear the label."},"opens_at":{"type":"string","format":"date-time"},"closes_at":{"type":"string","format":"date-time"},"audience":{"type":"string","enum":["assignees","roster"],"description":"Switching to assignees keeps the roster for a later switch back; switching to roster admits only whoever is on it."}}},"ScheduledQuizParticipantList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledQuizParticipant"}}},"required":["items"]},"ScheduledQuizParticipant":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"scheduled_quiz_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"]},"user_email":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","scheduled_quiz_id","user_id","user_display_name","user_email","created_at"]},"AddScheduledQuizParticipantRequest":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"A member of the quiz's organization who is assigned the quiz, individually or through a role they hold."}},"required":["user_id"]},"QuizAssignmentList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/QuizAssignment"}}},"required":["items"]},"QuizAssignment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"quiz_id":{"type":"string","format":"uuid"},"assignee_kind":{"type":"string","enum":["role","user"],"description":"role: everyone granted the role. user: one directory member."},"role_id":{"type":["string","null"],"format":"uuid"},"role_name":{"type":["string","null"]},"department_id":{"type":["string","null"],"format":"uuid"},"department_name":{"type":["string","null"]},"user_id":{"type":["string","null"],"format":"uuid","description":"User id of an individually assigned member."},"user_display_name":{"type":["string","null"]},"user_email":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","quiz_id","assignee_kind","role_id","role_name","department_id","department_name","user_id","user_display_name","user_email","created_at"]},"CreateQuizAssignmentRequest":{"type":"object","properties":{"role_id":{"type":"string","format":"uuid","description":"Assign to every holder of this department role."},"user_id":{"type":"string","format":"uuid","description":"Assign to one member of the quiz's organization, by user id."}}},"QuizDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the quiz."},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"footer_markdown":{"type":["string","null"],"description":"Markdown rendered beneath the last question of a sitting, above the submit button; null shows nothing."},"status":{"type":"string","enum":["draft","published"],"description":"Draft quizzes are invisible to assignees; published ones are takeable."},"delivery_kind":{"type":"string","enum":["live","async"],"description":"live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply."},"time_limit_seconds":{"type":["integer","null"]},"selection_mode":{"type":"string","enum":["fixed","shuffle","random_draw"],"description":"fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled."},"pass_percentage":{"type":"number"},"max_attempts":{"type":["integer","null"],"description":"Null means unlimited attempts."},"show_results_immediately":{"type":"boolean","description":"Whether a taker sees score and pass/fail right after submitting."},"show_correct_answers":{"type":"boolean","description":"Whether answer review (your answer vs. correct) is ever shown to takers."},"results_released_at":{"type":["string","null"],"format":"date-time","description":"Manual release stamp for held-back results. An attempt's results are visible to its taker when show_results_immediately, once the sitting it was sat in has closed, or once this is set."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"bank_links":{"type":"array","items":{"$ref":"#/components/schemas/QuizBankLink"},"description":"The banks this quiz draws from, in presentation order."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/QuizSubject"},"description":"Subject matters the per-taker learning record is filed under."},"schedules":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledQuizDetail"},"description":"The quiz's scheduled sittings in calendar order, each with its roster. A live quiz is takeable only while one of them is open — by every assignee, or by its roster alone, per the sitting's audience; async quizzes ignore them."}},"required":["id","owner_organization_id","owner_department_id","slug","name","description","footer_markdown","status","delivery_kind","time_limit_seconds","selection_mode","pass_percentage","max_attempts","show_results_immediately","show_correct_answers","results_released_at","archived_at","created_at","bank_links","subjects","schedules"]},"QuizBankLink":{"type":"object","properties":{"question_bank_id":{"type":"string","format":"uuid"},"bank_slug":{"type":"string"},"bank_name":{"type":"string"},"bank_archived_at":{"type":["string","null"],"format":"date-time","description":"When the linked bank was archived; null while it is live. An archived bank stays linked but feeds no new attempt until it is restored."},"position":{"type":"integer"},"draw_count":{"type":["integer","null"],"description":"Questions a random_draw quiz pulls from this bank; null = all."},"question_count":{"type":"integer","description":"Unarchived questions currently in the linked bank."}},"required":["question_bank_id","bank_slug","bank_name","bank_archived_at","position","draw_count","question_count"]},"QuizSubject":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"}},"required":["id","slug","name"]},"ScheduledQuizDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"quiz_id":{"type":"string","format":"uuid"},"label":{"type":["string","null"],"description":"Human label for the sitting, e.g. \"2026 annual recertification\"."},"opens_at":{"type":"string","format":"date-time"},"closes_at":{"type":"string","format":"date-time"},"audience":{"type":"string","enum":["assignees","roster"],"description":"assignees: open to everyone the quiz is assigned to. roster: open only to the trainees on this sitting's roster (see its participants), who must still be assignees of the quiz; a roster sitting with nobody on it is takeable by nobody."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"participants":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledQuizParticipant"},"description":"The sitting's roster, oldest entry first. Decides who may sit a roster sitting; kept but ignored while the audience is assignees."}},"required":["id","quiz_id","label","opens_at","closes_at","audience","created_at","participants"]},"SetQuizBankLinksRequest":{"type":"object","properties":{"links":{"type":"array","items":{"type":"object","properties":{"question_bank_id":{"type":"string","format":"uuid"},"draw_count":{"type":"integer","exclusiveMinimum":0,"description":"Questions a random_draw quiz pulls from this bank; omit for all."}},"required":["question_bank_id"]},"maxItems":50,"description":"Replaces the quiz's bank links; array order is presentation order."}},"required":["links"]},"SetQuizSubjectsRequest":{"type":"object","properties":{"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the quiz's subject-matter tags."}},"required":["subject_matter_ids"]},"Quiz":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the quiz."},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"footer_markdown":{"type":["string","null"],"description":"Markdown rendered beneath the last question of a sitting, above the submit button; null shows nothing."},"status":{"type":"string","enum":["draft","published"],"description":"Draft quizzes are invisible to assignees; published ones are takeable."},"delivery_kind":{"type":"string","enum":["live","async"],"description":"live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply."},"time_limit_seconds":{"type":["integer","null"]},"selection_mode":{"type":"string","enum":["fixed","shuffle","random_draw"],"description":"fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled."},"pass_percentage":{"type":"number"},"max_attempts":{"type":["integer","null"],"description":"Null means unlimited attempts."},"show_results_immediately":{"type":"boolean","description":"Whether a taker sees score and pass/fail right after submitting."},"show_correct_answers":{"type":"boolean","description":"Whether answer review (your answer vs. correct) is ever shown to takers."},"results_released_at":{"type":["string","null"],"format":"date-time","description":"Manual release stamp for held-back results. An attempt's results are visible to its taker when show_results_immediately, once the sitting it was sat in has closed, or once this is set."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","owner_organization_id","owner_department_id","slug","name","description","footer_markdown","status","delivery_kind","time_limit_seconds","selection_mode","pass_percentage","max_attempts","show_results_immediately","show_correct_answers","results_released_at","archived_at","created_at"]},"DuplicateQuizRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"description":"The copy's name; omit for the original's with \" (copy)\" appended."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","description":"The copy's slug, unique within the organization; omit for the first free `-copy`, `-copy-2`… of the original's. Slugs are lowercase letters and digits in words separated by single hyphens, like fire-safety-basics."}}},"QuizList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Quiz"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"CreateQuizRequest":{"type":"object","properties":{"owner_organization_id":{"type":"string","format":"uuid","description":"Organization the quiz belongs to. Always required."},"owner_department_id":{"type":"string","format":"uuid","description":"Omit for a quiz owned by the organization directly."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"annual-fire-safety","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like fire-safety-basics."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Annual fire safety quiz"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."},"footer_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no closing note.","example":"**Before you submit:** review every answer. Submission cannot be undone."},"delivery_kind":{"type":"string","enum":["live","async"],"description":"live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply."},"time_limit_seconds":{"type":"integer","exclusiveMinimum":0,"description":"Per-attempt time limit in seconds; a live sitting's close still caps it.","example":1200},"selection_mode":{"type":"string","enum":["fixed","shuffle","random_draw"],"description":"fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled."},"pass_percentage":{"type":"number","minimum":0,"maximum":100,"description":"Score required to pass, as a percentage of the questions presented.","example":80},"max_attempts":{"type":"integer","exclusiveMinimum":0,"description":"Omit for unlimited attempts.","example":3},"show_results_immediately":{"type":"boolean","description":"Defaults to true."},"show_correct_answers":{"type":"boolean","description":"Defaults to false."},"bank_links":{"type":"array","items":{"type":"object","properties":{"question_bank_id":{"type":"string","format":"uuid"},"draw_count":{"type":"integer","exclusiveMinimum":0,"description":"Questions a random_draw quiz pulls from this bank; omit for all."}},"required":["question_bank_id"]},"maxItems":50,"description":"Question banks to link from the start, in presentation order; each must belong to the owning organization. Omit to link banks later via PUT /quizzes/{quiz_id}/banks."}},"required":["owner_organization_id","slug","name","delivery_kind","selection_mode","pass_percentage"]},"UpdateQuizRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null to clear the description."},"footer_markdown":{"type":["string","null"],"maxLength":50000,"description":"Send null or empty to clear the closing note.","example":"**Before you submit:** review every answer. Submission cannot be undone."},"delivery_kind":{"type":"string","enum":["live","async"],"description":"live: takeable only while one of the quiz's scheduled sittings (scheduled_quizzes) is open — the same quiz can be scheduled year after year. async: self-scheduled, takeable whenever the quiz is published; sittings do not apply."},"time_limit_seconds":{"type":["integer","null"],"exclusiveMinimum":0,"description":"Send null to remove the time limit.","example":1200},"selection_mode":{"type":"string","enum":["fixed","shuffle","random_draw"],"description":"fixed: every question in authored order, the same for everyone. shuffle: every question, order and options shuffled per attempt. random_draw: each bank link contributes draw_count randomly chosen questions (all when null), shuffled."},"pass_percentage":{"type":"number","minimum":0,"maximum":100,"description":"Score required to pass, as a percentage of the questions presented.","example":80},"max_attempts":{"type":["integer","null"],"exclusiveMinimum":0,"description":"Send null for unlimited attempts.","example":3},"show_results_immediately":{"type":"boolean"},"show_correct_answers":{"type":"boolean"}}},"MyPathways":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/PathwayProgress"},"description":"Every unarchived pathway assigned to the caller, with derived progress."}},"required":["items"]},"PathwayProgress":{"type":"object","properties":{"pathway_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description_markdown":{"type":["string","null"]},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/PathwayAttachment"},"description":"The pathway's trainee handouts, in authored order. Trainer resources are never part of progress; the authoring read carries them."},"job_aids":{"type":"array","items":{"$ref":"#/components/schemas/JobAidRef"},"description":"The job aids presented on the pathway that reach the viewer, in authored order — an aid outside their audience, or archived, is left out."},"phases":{"type":"array","items":{"$ref":"#/components/schemas/PathwayPhaseProgress"},"description":"Every phase in dependency order (creation order between unrelated phases), each with its derived state."},"levels":{"type":"array","items":{"$ref":"#/components/schemas/PathwayLevelProgress"},"description":"Every level in authored order, each with its derived state."},"current_level_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"The unlocked-but-incomplete levels — where the viewer is right now."},"complete":{"type":"boolean","description":"Whether every level is complete for the viewer."}},"required":["pathway_id","slug","name","description_markdown","attachments","job_aids","phases","levels","current_level_ids","complete"]},"PathwayAttachment":{"allOf":[{"$ref":"#/components/schemas/DescriptionAttachment"},{"type":"object","properties":{"audience":{"type":"string","enum":["trainees","trainers"],"description":"trainees: a handout for everyone the pathway is assigned to. trainers: a guide or lesson plan only holders of certifications:read over the pathway's owner can see."}},"required":["audience"]}]},"JobAidRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"description":{"type":["string","null"]},"audience":{"type":"string","enum":["trainees","trainers"],"description":"Whom the job aid is for. trainees: everyone its ownership admits — every member for an organization-owned aid, the owning department's role holders for a department-owned one — trainers included. trainers: only holders of job-aids:read over the aid's owner, for facilitator guides and the like; the aid is absent from everyone else's list."},"archived":{"type":"boolean","description":"Whether the aid is archived — hidden from its audience."}},"required":["id","title","description","audience","archived"]},"PathwayPhaseProgress":{"type":"object","properties":{"phase_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description_markdown":{"type":["string","null"]},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"The phases this one comes after — an ordering for display, never a gate."},"level_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"The levels grouped under this phase, in authored order."},"state":{"type":"string","enum":["locked","unlocked","complete"],"description":"complete: every level in the phase is complete (vacuously when it has none). unlocked: at least one level is unlocked or complete. locked: every level is still locked. Presentation only."},"level_count":{"type":"integer"},"complete_level_count":{"type":"integer"}},"required":["phase_id","name","description_markdown","depends_on_phase_ids","level_ids","state","level_count","complete_level_count"]},"PathwayLevelProgress":{"type":"object","properties":{"level_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"description_markdown":{"type":["string","null"]},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/PathwayAttachment"},"description":"The level's trainee handouts, in authored order. Trainer resources are never part of progress; the authoring read carries them."},"job_aids":{"type":"array","items":{"$ref":"#/components/schemas/JobAidRef"},"description":"The job aids presented on the level that reach the viewer, in authored order — an aid outside their audience, or archived, is left out."},"position":{"type":"integer"},"phase_id":{"type":["string","null"],"format":"uuid","description":"The phase grouping this level, or null for an ungrouped level."},"depends_on_level_ids":{"type":"array","items":{"type":"string","format":"uuid"}},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Phases every level of which must be complete before this one unlocks."},"state":{"type":"string","enum":["locked","unlocked","complete"],"description":"complete: every applicable certification is awarded (vacuously complete when none applies). unlocked: every prerequisite level, and every level of every prerequisite phase, is complete. locked: rendered collapsed — presentation only, nothing is enforced server-side."},"assignment":{"type":"string","enum":["assigned","nothing_assigned","empty"],"description":"assigned: at least one certification in the level is required of the viewer. nothing_assigned: the level holds certifications, but none is required of any of the viewer's roles — it derives as complete without their earning anything, and the pages show it as \"nothing assigned\" rather than \"complete\". empty: no certification is placed in the level yet."},"applicable_count":{"type":"integer","description":"Certifications in the level that apply to the viewer."},"awarded_count":{"type":"integer","description":"Of the applicable ones, how many the viewer currently holds."},"has_overdue":{"type":"boolean","description":"Whether any applicable certification in the level is past due — the warning badge on an otherwise complete level. Never re-locks anything."},"certifications":{"type":"array","items":{"$ref":"#/components/schemas/PathwayCertProgress"}}},"required":["level_id","name","description","description_markdown","attachments","job_aids","position","phase_id","depends_on_level_ids","depends_on_phase_ids","state","assignment","applicable_count","awarded_count","has_overdue","certifications"]},"PathwayCertProgress":{"type":"object","properties":{"certification_type_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"applies_to_user":{"type":"boolean","description":"Whether one of the viewer's unexpired role grants requires this type. Only applicable certifications count towards level completion."},"awarded":{"type":"boolean","description":"Whether the viewer currently holds a non-revoked award of this type."},"overdue":{"type":"boolean","description":"Whether the requirement is past due for the viewer (initial or recertification). Always false on certifications that do not apply — a warning badge, never a lock."}},"required":["certification_type_id","slug","name","applies_to_user","awarded","overdue"]},"CreatePathwayAttachmentRequest":{"allOf":[{"$ref":"#/components/schemas/CreateDescriptionAttachmentRequest"},{"type":"object","properties":{"audience":{"type":"string","enum":["trainees","trainers"],"description":"Omit for a trainee handout."}}}]},"UpdatePathwayAttachmentRequest":{"allOf":[{"$ref":"#/components/schemas/UpdateDescriptionAttachmentRequest"},{"type":"object","properties":{"audience":{"type":"string","enum":["trainees","trainers"],"description":"Moves the attachment between the trainee handouts and the trainer resources."}}}]},"SetLinkedJobAidsRequest":{"type":"object","properties":{"job_aid_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Replaces the whole list of job aids presented here, in the given order. Aids of the pathway's own organization only."}},"required":["job_aid_ids"]},"PathwayLevel":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"pathway_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description":{"type":["string","null"]},"description_markdown":{"type":["string","null"],"description":"Long-form narrative markdown below the summary line; never parsed."},"position":{"type":"integer","description":"Authored order — the stable tie-break parallel branches render in."},"phase_id":{"type":["string","null"],"format":"uuid","description":"The phase grouping this level, or null for an ungrouped level."},"depends_on_level_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Levels that must all be complete before this one unlocks; empty for a root level."},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"Phases every level of which must be complete before this one unlocks, on top of depends_on_level_ids. Never the level's own phase."},"certifications":{"type":"array","items":{"$ref":"#/components/schemas/PathwayLevelCertification"},"description":"The certification types this level groups, in authored order."},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/PathwayAttachment"},"description":"Every description attachment of the level, trainee handouts and trainer resources alike, in authored order. The bytes are fetched through GET /learning-pathway-levels/{level_id}/attachments/{attachment_id}/download."},"job_aids":{"type":"array","items":{"$ref":"#/components/schemas/JobAidRef"},"description":"The job aids presented on the level, in authored order — reading material only; nothing about progress depends on them. Replaced through PUT /learning-pathway-levels/{level_id}/job-aids."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","pathway_id","name","description","description_markdown","position","phase_id","depends_on_level_ids","depends_on_phase_ids","certifications","attachments","job_aids","created_at"]},"PathwayLevelCertification":{"type":"object","properties":{"certification_type_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"archived":{"type":"boolean","description":"Whether the certification type itself has been archived since placement."}},"required":["certification_type_id","slug","name","archived"]},"CreatePathwayLevelRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"example":"Level 1: Foundations"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."},"description_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no long-form markdown description."},"phase_id":{"type":["string","null"],"format":"uuid","description":"An existing phase of the same pathway to group the level under. Omit for none."},"depends_on_level_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Existing levels of the same pathway this one depends on. The edges must keep the pathway acyclic. Omit for a root level."},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Existing phases of the same pathway this one depends on — every level of each must be complete first. Never the level's own phase; the expanded edges must stay acyclic."},"certification_type_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Certification types of the same organization to place in this level, in order. A type may appear in at most one level per pathway."}},"required":["name"]},"UpdatePathwayLevelRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null to clear the description."},"description_markdown":{"type":["string","null"],"maxLength":50000,"description":"Send null to clear the long-form markdown description."},"position":{"type":"integer","minimum":0},"phase_id":{"type":["string","null"],"format":"uuid","description":"Moves the level into a phase of its pathway; send null to ungroup it."},"depends_on_level_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the level's whole level-prerequisite set; must stay acyclic."},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the level's whole phase-prerequisite set; never its own phase, and the expanded edges must stay acyclic."},"certification_type_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":100,"description":"Replaces the level's whole certification list, in the given order."}}},"PathwayPhase":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"pathway_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"description_markdown":{"type":["string","null"],"description":"Narrative markdown shown at the head of the phase; never parsed."},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"The phases this one comes after. An ordering the pages lay the phases out in — never a gate; levels gate on phases through their own depends_on_phase_ids."},"level_ids":{"type":"array","items":{"type":"string","format":"uuid"},"description":"The levels grouped under this phase, in authored order."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","pathway_id","name","description_markdown","depends_on_phase_ids","level_ids","created_at"]},"CreatePathwayPhaseRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200,"example":"Phase 1: Onboarding"},"description_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no markdown description."},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Existing phases of the same pathway this one comes after. The edges must keep the phases acyclic. Omit for a first phase."}},"required":["name"]},"UpdatePathwayPhaseRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description_markdown":{"type":["string","null"],"maxLength":50000,"description":"Send null to clear the markdown description."},"depends_on_phase_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the whole set of phases this one comes after; must stay acyclic."}}},"PathwayAssignment":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"pathway_id":{"type":"string","format":"uuid"},"assignee_kind":{"type":"string","enum":["role","department","organization"]},"role_id":{"type":["string","null"],"format":"uuid"},"role_name":{"type":["string","null"]},"department_id":{"type":["string","null"],"format":"uuid","description":"The role's department on a role assignment, the audience itself on a department one (covering departments nested beneath it too), null on an organization one."},"department_name":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","pathway_id","assignee_kind","role_id","role_name","department_id","department_name","created_at"]},"CreatePathwayAssignmentRequest":{"type":"object","properties":{"assignee_kind":{"type":"string","enum":["role","department","organization"],"description":"role: holders of role_id. department: everyone with a role grant in department_id or any department nested beneath it. organization: every member."},"role_id":{"type":"string","format":"uuid","description":"Required for role assignments; must be a role of the pathway's organization."},"department_id":{"type":"string","format":"uuid","description":"Required for department assignments; forbidden for organization ones."}},"required":["assignee_kind"]},"PathwayList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Pathway"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"Pathway":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Null when the organization itself owns the pathway."},"slug":{"type":"string"},"name":{"type":"string"},"description_markdown":{"type":["string","null"],"description":"Narrative markdown rendered on the pathway page; never parsed for structure."},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","owner_organization_id","owner_department_id","slug","name","description_markdown","archived_at","created_at"]},"CreatePathwayRequest":{"type":"object","properties":{"owner_organization_id":{"type":"string","format":"uuid","description":"Organization the pathway belongs to. Always required."},"owner_department_id":{"type":"string","format":"uuid","description":"Omit for a pathway owned by the organization directly."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"new-hire-path","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like new-hire-path."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"New hire path"},"description_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no markdown description."}},"required":["owner_organization_id","slug","name"]},"PathwayDetail":{"type":"object","properties":{"journey":{"$ref":"#/components/schemas/Pathway"},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/PathwayAttachment"},"description":"Every description attachment of the pathway, trainee handouts and trainer resources alike, in authored order. The bytes are fetched through GET /learning-pathways/{pathway_id}/attachments/{attachment_id}/download."},"job_aids":{"type":"array","items":{"$ref":"#/components/schemas/JobAidRef"},"description":"The job aids presented on the pathway as a whole, in authored order. Replaced through PUT /learning-pathways/{pathway_id}/job-aids."},"phases":{"type":"array","items":{"$ref":"#/components/schemas/PathwayPhase"},"description":"Every phase with the phases it comes after and its levels, in dependency order."},"levels":{"type":"array","items":{"$ref":"#/components/schemas/PathwayLevel"},"description":"Every level with its prerequisites and contents, in authored order."},"assignments":{"type":"array","items":{"$ref":"#/components/schemas/PathwayAssignment"},"description":"Who the pathway is presented to."}},"required":["journey","attachments","job_aids","phases","levels","assignments"]},"UpdatePathwayRequest":{"type":"object","properties":{"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$"},"name":{"type":"string","minLength":1,"maxLength":200},"description_markdown":{"type":["string","null"],"maxLength":50000,"description":"Send null to clear the markdown description."}}},"ExternalLmsServerList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ExternalLmsServer"}}},"required":["items"]},"ExternalLmsServer":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":["string","null"]},"base_url":{"type":["string","null"],"description":"Where the server lives, for humans; the LRS never calls it."},"lms_kind":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."}},"required":["id","organization_id","slug","name","description","base_url","lms_kind","created_at","archived_at"]},"CreateExternalLmsServerRequest":{"type":"object","properties":{"organization_id":{"type":"string","format":"uuid","description":"Organization whose catalogue the server belongs to."},"slug":{"type":"string","minLength":1,"maxLength":100,"pattern":"^[a-z0-9]+(-[a-z0-9]+)*$","example":"moodle-eu","description":"Unique within the organization. Slugs are lowercase letters and digits in words separated by single hyphens, like moodle-eu."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Moodle (EU campus)"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no description."},"base_url":{"type":"string","maxLength":2000,"format":"uri","example":"https://moodle.example.com"},"lms_kind":{"type":"string","minLength":1,"maxLength":100,"description":"Free-text kind of the server, e.g. \"moodle\" or \"scorm-cloud\"; informational.","example":"moodle"}},"required":["organization_id","slug","name"]},"IngestExternalGradesResult":{"type":"object","properties":{"created":{"type":"integer"},"duplicates":{"type":"integer"},"errors":{"type":"integer"},"outcomes":{"type":"array","items":{"$ref":"#/components/schemas/ExternalGradeOutcome"},"description":"One outcome per submitted row, in row order."}},"required":["created","duplicates","errors","outcomes"]},"ExternalGradeOutcome":{"type":"object","properties":{"index":{"type":"integer","description":"0-based position of the row in the submitted batch (or CSV data rows)."},"status":{"type":"string","enum":["created","duplicate","error"]},"error_description":{"type":["string","null"],"description":"Why the row was rejected; null unless status is error."},"learning_record_id":{"type":["string","null"],"format":"uuid","description":"The completed learning record a created row produced."}},"required":["index","status","error_description","learning_record_id"]},"ExternalGradesImportForm":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"CSV file, at most 1 MB: a header row naming an `external_course_id`, `score` (0-100) and `completed_at` (ISO date) column, plus an `external_user_id` and/or `learner_user_id` column, in any order; `passed` and `external_attempt_id` columns are optional. Header matching is case-insensitive and extra columns are ignored."}},"required":["file"]},"IngestExternalGradesRequest":{"type":"object","properties":{"grades":{"type":"array","items":{"$ref":"#/components/schemas/ExternalGradeRow"},"minItems":1,"maxItems":1000}},"required":["grades"]},"ExternalGradeRow":{"type":"object","properties":{"external_course_id":{"type":"string","minLength":1,"maxLength":200,"description":"The LMS's own course identifier, resolved against the server's catalogue.","example":"SAFETY-101"},"external_user_id":{"type":"string","minLength":1,"maxLength":200,"description":"The LMS's identity of the learner, resolved through the server's user mappings. Provide this or user_id.","example":"jdoe"},"user_id":{"type":"string","format":"uuid","description":"The directory member's users.id, for writers that already know it (e.g. manual entry)."},"score":{"type":"number","minimum":0,"maximum":100,"description":"Normalized 0-100 score of the attempt.","example":87.5},"passed":{"type":"boolean","description":"The LMS's own verdict. Decides passing when the course sets no passing_score."},"completed_at":{"type":"string","format":"date-time","description":"When the attempt was completed on the LMS."},"external_attempt_id":{"type":"string","minLength":1,"maxLength":200,"description":"The LMS's own id of this attempt. The strongest duplicate fence: re-imports of the same id are skipped. Rows without one dedupe on (course, learner, completed_at)."}},"required":["external_course_id","score","completed_at"]},"ExternalCourseGradeList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ExternalCourseGrade"}}},"required":["items"]},"ExternalCourseGrade":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"course_id":{"type":"string","format":"uuid"},"course_name":{"type":"string"},"course_external_id":{"type":"string"},"learner_user_id":{"type":"string","format":"uuid"},"external_user_id":{"type":["string","null"]},"external_attempt_id":{"type":["string","null"]},"score":{"type":"number"},"passed":{"type":["boolean","null"]},"completed_at":{"type":"string","format":"date-time"},"learning_record_id":{"type":"string","format":"uuid"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","course_id","course_name","course_external_id","learner_user_id","external_user_id","external_attempt_id","score","passed","completed_at","learning_record_id","created_at"]},"ExternalLmsUserMappingList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ExternalLmsUserMapping"}}},"required":["items"]},"ExternalLmsUserMapping":{"type":"object","properties":{"server_id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"external_user_id":{"type":"string"},"user_id":{"type":"string","format":"uuid"},"display_name":{"type":["string","null"],"description":"The member's profile name from users, for listings."},"email":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["server_id","organization_id","external_user_id","user_id","display_name","email","created_at"]},"UpsertExternalLmsUserMappingRequest":{"type":"object","properties":{"external_user_id":{"type":"string","minLength":1,"maxLength":200,"example":"jdoe","description":"The LMS's own identity of the learner. Writing an existing one repoints it."},"user_id":{"type":"string","format":"uuid","description":"users.id of the directory member the identity resolves to."}},"required":["external_user_id","user_id"]},"ExternalLmsCourseList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ExternalLmsCourse"}}},"required":["items"]},"ExternalLmsCourse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"server_id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"external_course_id":{"type":"string","description":"The LMS's own identifier — the key grades arrive under; unique per server."},"name":{"type":"string"},"description":{"type":["string","null"]},"course_url":{"type":["string","null"]},"passing_score":{"type":["number","null"],"description":"Score required to pass, 0-100. Authoritative when set; when null, the imported passed flag decides."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/ExternalCourseSubject"},"description":"The subject matters imported grade records are filed under, alphabetically; empty for an untagged course."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"When set, the record is retired; it stays readable but should not grow."}},"required":["id","server_id","organization_id","external_course_id","name","description","course_url","passing_score","subjects","created_at","archived_at"]},"ExternalCourseSubject":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"}},"required":["id","slug","name"]},"CreateExternalLmsCourseRequest":{"type":"object","properties":{"external_course_id":{"type":"string","minLength":1,"maxLength":200,"example":"SAFETY-101","description":"The LMS's own identifier of the course. Unique within the server; immutable."},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Workplace safety basics"},"description":{"type":"string","maxLength":2000},"course_url":{"type":"string","maxLength":2000,"format":"uri","example":"https://moodle.example.com/course/view.php?id=42"},"passing_score":{"type":"number","minimum":0,"maximum":100,"description":"Score required to pass, 0-100. Omit to let the imported passed flag decide.","example":80},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Subject matters of the server's own organization to file imported grade records under; all of them. Omit for an untagged course."}},"required":["external_course_id","name"]},"UpdateExternalLmsServerRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null to clear the description."},"base_url":{"type":["string","null"],"maxLength":2000,"format":"uri","description":"Send null to clear the URL."},"lms_kind":{"type":["string","null"],"minLength":1,"maxLength":100,"description":"Send null to clear the kind.","example":"moodle"}}},"UpdateExternalLmsCourseRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000},"course_url":{"type":["string","null"],"maxLength":2000,"format":"uri","description":"Send null to clear the URL."},"passing_score":{"type":["number","null"],"minimum":0,"maximum":100,"description":"Send null to clear the pass mark (the imported flag then decides)."},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the course's subject-matter tags; send an empty array to stop tagging imported records. Records already imported keep their tags."}}},"AvailableScormCourseList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AvailableScormCourse"}}},"required":["items"]},"AvailableScormCourse":{"type":"object","properties":{"course":{"$ref":"#/components/schemas/ScormCourse"},"organization_name":{"type":"string"},"in_progress_attempt":{"$ref":"#/components/schemas/ScormAttempt"},"latest_attempt":{"allOf":[{"$ref":"#/components/schemas/ScormAttempt"},{"description":"The learner's most recent attempt, in progress or closed."}]},"attempts_used":{"type":"integer"},"completion_superseded":{"type":"boolean","description":"True when the learner has completed the course, but only on package versions before the one that required recertification — so the course must be completed again for certification. False while no version required recertification, before any completion, and once a completion of that version or a later one is on record."}},"required":["course","organization_name","in_progress_attempt","latest_attempt","attempts_used","completion_superseded"]},"ScormCourse":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Owning department within the organization; null = owned by the organization directly. Scopes which scorm:read/scorm:write grants cover the course, and narrows who may take it: an organization-owned course is open to every member, a department-owned one only to the owning department's role holders."},"name":{"type":"string"},"description":{"type":["string","null"]},"audience":{"type":"string","enum":["trainees","trainers"],"description":"Whom the course is for. trainees: everyone its ownership admits — every member for an organization-owned course, the owning department's role holders for a department-owned one — trainers included. trainers: only holders of scorm:read over the course's owner, for modules that teach how to run the training rather than the training itself; the course is absent from everyone else's list and launches."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/ScormCourseSubject"},"description":"The subject matters every completion record is filed under, alphabetically; empty for an untagged course."},"scorm_version":{"type":"string","enum":["scorm-1.2","scorm-2004","unknown"],"description":"SCORM revision detected from the package's imsmanifest.xml.","example":"scorm-1.2"},"launch_path":{"type":"string","description":"Zip-entry path (optionally with a query string) of the launch resource."},"mastery_score":{"type":["number","null"],"description":"SCORM 1.2 adlcp:masteryscore (0-100) from the manifest, if declared."},"package_file_name":{"type":"string"},"package_size_bytes":{"type":"integer"},"package_replaced_at":{"type":["string","null"],"format":"date-time","description":"When the package was last replaced in place through PUT /scorm-courses/{course_id}/package; null while the original upload is served."},"current_package_version":{"type":"integer","description":"The package version the course serves to fresh attempts: 1 for the registered upload, one more per replacement. The package fields above describe it; GET /scorm-courses/{course_id}/package-versions lists every version."},"recertification_required_at":{"type":["string","null"],"format":"date-time","description":"When recertification was last required by a package replacement, for display; null when every completion on record still counts."},"recertification_required_from_version":{"type":["integer","null"],"description":"Completions count from this package version on: a completion of an attempt on an earlier version no longer satisfies scorm_course_complete rules, so certified learners must complete the course again. Null when every version counts."},"created_by":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"An archived course is hidden from learners and takes no new attempts."}},"required":["id","organization_id","owner_department_id","name","description","audience","subjects","scorm_version","launch_path","mastery_score","package_file_name","package_size_bytes","package_replaced_at","current_package_version","recertification_required_at","recertification_required_from_version","created_by","created_at","archived_at"]},"ScormCourseSubject":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"}},"required":["id","slug","name"]},"ScormAttempt":{"type":["object","null"],"properties":{"id":{"type":"string","format":"uuid"},"course_id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"attempt_number":{"type":"integer"},"package_version_number":{"type":"integer","description":"The course package version the attempt was launched against and keeps running, whatever the course serves now: 1 for the registered upload, one more per replacement."},"status":{"type":"string","enum":["in_progress","closed"]},"api_flavor":{"type":["string","null"],"enum":["scorm-1.2","scorm-2004",null]},"completion_status":{"type":"string","enum":["completed","incomplete","not_attempted","unknown"]},"success_status":{"type":"string","enum":["passed","failed","unknown"]},"score_raw":{"type":["number","null"]},"score_max":{"type":["number","null"]},"score_scaled":{"type":["number","null"],"description":"0..1 (SCORM 2004 allows -1..1); from cmi.score.scaled, else raw/min/max."},"progress_measure":{"type":["number","null"]},"location":{"type":["string","null"],"description":"Where the learner left off (cmi location), as the module reported it."},"total_time_seconds":{"type":"number","description":"Learning time accumulated across every session of this attempt."},"started_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"last_commit_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"passed":{"type":["boolean","null"],"description":"From success_status: passed/failed => true/false, unknown => null."},"closed_at":{"type":["string","null"],"format":"date-time"},"learning_record_id":{"type":["string","null"],"format":"uuid","description":"The learning record this attempt's completion reported into, if any."}},"required":["id","course_id","organization_id","user_id","attempt_number","package_version_number","status","api_flavor","completion_status","success_status","score_raw","score_max","score_scaled","progress_measure","location","total_time_seconds","started_at","last_commit_at","completed_at","passed","closed_at","learning_record_id"]},"ScormLaunch":{"type":"object","properties":{"attempt":{"$ref":"#/components/schemas/ScormAttempt"},"course":{"$ref":"#/components/schemas/ScormCourse"},"entry":{"type":"string","enum":["ab-initio","resume"],"description":"resume seeds the saved CMI map so the module restores its state."},"cmi":{"type":"object","additionalProperties":{"type":"string","maxLength":65536},"description":"SCORM CMI elements by name, e.g. cmi.core.lesson_status."},"total_time_seconds":{"type":"number"},"objective_ids":{"type":"array","items":{"type":"string"},"description":"Manifest-declared objectives the runtime seeds, like a real LMS."},"mastery_score":{"type":["number","null"]},"learner_id":{"type":"string"},"learner_name":{"type":"string"},"content_url":{"type":"string","description":"Where the launch resource is served from. Relative asset URLs inside the module resolve under the same signed content prefix."}},"required":["attempt","course","entry","cmi","total_time_seconds","objective_ids","mastery_score","learner_id","learner_name","content_url"]},"LaunchScormCourseRequest":{"type":"object","properties":{"restart":{"type":"boolean","description":"True closes any attempt in progress and starts a fresh one; the default resumes it."}}},"CommitScormAttemptRequest":{"type":"object","properties":{"cmi":{"type":"object","additionalProperties":{"type":"string","maxLength":65536},"description":"SCORM CMI elements by name, e.g. cmi.core.lesson_status."},"api_flavor":{"type":["string","null"],"enum":["scorm-1.2","scorm-2004",null]},"terminated":{"type":"boolean","description":"True once the module has called LMSFinish / Terminate this session."}},"required":["cmi","api_flavor","terminated"]},"ScormPackageUploadTicket":{"type":"object","properties":{"pathname":{"type":"string","description":"Server-chosen blob pathname the signed upload URL writes to."},"upload_url":{"type":"string"},"expires_at":{"type":"string","format":"date-time"}},"required":["pathname","upload_url","expires_at"]},"ScormPackageUploadRequest":{"type":"object","properties":{"org":{"type":"string","minLength":1,"description":"Organization UUID id or slug the course will belong to.","example":"acme-corporation"},"department":{"type":"string","minLength":1,"description":"UUID id or slug of the owning department within the organization; omit for a course owned by the organization directly."},"file_name":{"type":"string","minLength":1,"maxLength":255},"size_bytes":{"type":"integer","exclusiveMinimum":0,"maximum":314572800,"description":"Declared package size; the upload token enforces the cap."}},"required":["org","file_name","size_bytes"]},"ScormCourseList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ScormCourse"}}},"required":["items"]},"CreateScormCourseRequest":{"type":"object","properties":{"org":{"type":"string","minLength":1,"description":"Organization UUID id or slug the course belongs to.","example":"acme-corporation"},"department":{"type":"string","minLength":1,"description":"UUID id or slug of the owning department within the organization; omit for a course owned by the organization directly. Ownership scopes administration and limits taking the course to the department's role holders."},"pathname":{"type":"string","pattern":"^scorm-courses\\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\/[^/]{1,100}$"},"file_name":{"type":"string","minLength":1,"maxLength":255},"name":{"type":"string","maxLength":200,"description":"Defaults to the title in the package manifest, then the file name."},"description":{"type":"string","maxLength":5000},"audience":{"type":"string","enum":["trainees","trainers"],"description":"Defaults to trainees: open to everyone the course's ownership admits."},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Subjects of the course's organization to tag it with; completion records are filed under all of them. Omit for an untagged course."}},"required":["org","pathname","file_name"]},"ScormCourseAttemptList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ScormCourseAttempt"}},"next_cursor":{"type":["string","null"],"description":"Pass as ?cursor= to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"ScormCourseAttempt":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"course_id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"attempt_number":{"type":"integer"},"package_version_number":{"type":"integer","description":"The course package version the attempt was launched against and keeps running, whatever the course serves now: 1 for the registered upload, one more per replacement."},"status":{"type":"string","enum":["in_progress","closed"]},"api_flavor":{"type":["string","null"],"enum":["scorm-1.2","scorm-2004",null]},"completion_status":{"type":"string","enum":["completed","incomplete","not_attempted","unknown"]},"success_status":{"type":"string","enum":["passed","failed","unknown"]},"score_raw":{"type":["number","null"]},"score_max":{"type":["number","null"]},"score_scaled":{"type":["number","null"],"description":"0..1 (SCORM 2004 allows -1..1); from cmi.score.scaled, else raw/min/max."},"progress_measure":{"type":["number","null"]},"location":{"type":["string","null"],"description":"Where the learner left off (cmi location), as the module reported it."},"total_time_seconds":{"type":"number","description":"Learning time accumulated across every session of this attempt."},"started_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"last_commit_at":{"type":["string","null"],"format":"date-time"},"completed_at":{"type":["string","null"],"format":"date-time"},"passed":{"type":["boolean","null"],"description":"From success_status: passed/failed => true/false, unknown => null."},"closed_at":{"type":["string","null"],"format":"date-time"},"learning_record_id":{"type":["string","null"],"format":"uuid","description":"The learning record this attempt's completion reported into, if any."},"user_display_name":{"type":["string","null"]},"user_email":{"type":["string","null"]}},"required":["id","course_id","organization_id","user_id","attempt_number","package_version_number","status","api_flavor","completion_status","success_status","score_raw","score_max","score_scaled","progress_measure","location","total_time_seconds","started_at","last_commit_at","completed_at","passed","closed_at","learning_record_id","user_display_name","user_email"]},"SetScormCourseSubjectsRequest":{"type":"object","properties":{"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the course's subject-matter tags."}},"required":["subject_matter_ids"]},"ScormPackageReplacementUploadRequest":{"type":"object","properties":{"file_name":{"type":"string","minLength":1,"maxLength":255},"size_bytes":{"type":"integer","exclusiveMinimum":0,"maximum":314572800,"description":"Declared package size; the upload token enforces the cap."}},"required":["file_name","size_bytes"]},"ReplaceScormCoursePackageRequest":{"type":"object","properties":{"pathname":{"type":"string","pattern":"^scorm-courses\\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\/[^/]{1,100}$"},"file_name":{"type":"string","minLength":1,"maxLength":255},"requires_recertification":{"type":"boolean","description":"True when the new content must be completed again by everyone — new compliance material, say: completions of attempts on earlier package versions stop satisfying scorm_course_complete rules, an attempt still running an earlier version included. False for a minor fix — a typo, a broken link — that leaves every earlier completion standing. Attempts in progress keep running the version they started on either way."}},"required":["pathname","file_name","requires_recertification"]},"ScormPackageVersionList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ScormPackageVersion"}}},"required":["items"]},"ScormPackageVersion":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"course_id":{"type":"string","format":"uuid"},"version_number":{"type":"integer"},"scorm_version":{"type":"string","enum":["scorm-1.2","scorm-2004","unknown"],"description":"SCORM revision detected from the package's imsmanifest.xml.","example":"scorm-1.2"},"launch_path":{"type":"string"},"mastery_score":{"type":["number","null"]},"package_file_name":{"type":"string"},"package_size_bytes":{"type":"integer"},"requires_recertification":{"type":"boolean","description":"Whether this upload required recertification: completions of earlier versions stopped satisfying the course's proof rules with it."},"current":{"type":"boolean","description":"Whether this is the version fresh attempts launch against."},"uploaded_by":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"superseded_at":{"type":["string","null"],"format":"date-time","description":"When a later version took over; null on the current version."}},"required":["id","course_id","version_number","scorm_version","launch_path","mastery_score","package_file_name","package_size_bytes","requires_recertification","current","uploaded_by","created_at","superseded_at"]},"UpdateScormCourseRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":5000},"audience":{"type":"string","enum":["trainees","trainers"],"description":"Whom the course is for. trainees: everyone its ownership admits — every member for an organization-owned course, the owning department's role holders for a department-owned one — trainers included. trainers: only holders of scorm:read over the course's owner, for modules that teach how to run the training rather than the training itself; the course is absent from everyone else's list and launches."},"archived":{"type":"boolean","description":"True archives the course (hidden from learners); false restores it."}}},"AvailableJobAidList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/AvailableJobAid"}}},"required":["items"]},"AvailableJobAid":{"type":"object","properties":{"job_aid":{"$ref":"#/components/schemas/JobAid"},"organization_name":{"type":"string"},"owner_department_name":{"type":["string","null"]},"last_viewed_at":{"type":["string","null"],"format":"date-time","description":"When the caller last opened the aid's page; null before the first time."},"view_count":{"type":"integer","description":"How many times the caller has opened the aid's page."},"view_superseded":{"type":"boolean","description":"True when the caller's last open predates the aid's recertification cutoff — its content was replaced with recertification required since — so the aid must be opened again for certification. False while no cutoff applies, before the first open, and once an open follows the cutoff."}},"required":["job_aid","organization_name","owner_department_name","last_viewed_at","view_count","view_superseded"]},"JobAid":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Owning department within the organization; null = owned by the organization directly. Scopes which job-aids:read/write grants cover the aid, and narrows who may read it: an organization-owned aid reaches every member, a department-owned one only the owning department's role holders."},"title":{"type":"string"},"description":{"type":["string","null"],"description":"One-line summary shown in listings."},"body_markdown":{"type":["string","null"],"description":"Narrative markdown rendered on the aid's page; never parsed. A job aid may be this body alone, files alone, or both."},"audience":{"type":"string","enum":["trainees","trainers"],"description":"Whom the job aid is for. trainees: everyone its ownership admits — every member for an organization-owned aid, the owning department's role holders for a department-owned one — trainers included. trainers: only holders of job-aids:read over the aid's owner, for facilitator guides and the like; the aid is absent from everyone else's list."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/JobAidSubject"},"description":"The subject matters the aid is tagged with, alphabetically."},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/DescriptionAttachment"},"description":"Every file of the aid, in authored order. The bytes are fetched through GET /job-aids/{job_aid_id}/attachments/{attachment_id}/download, which also records a view of that file."},"links":{"type":"array","items":{"$ref":"#/components/schemas/JobAidLink"},"description":"Every link of the aid, in authored order. Files and links share one `position` sequence, so a reader's list interleaves the two by it; opening a link records no view, since it leaves this deployment."},"recertification_required_at":{"type":["string","null"],"format":"date-time","description":"The recertification cutoff: opens of the aid's page before this instant no longer satisfy job_aid_viewed rules, so certified readers must open it again. Set when a file is replaced, or the aid edited, with recertification required; null when every open on record still counts."},"created_by":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"An archived job aid is hidden from its audience; its views stay on record."}},"required":["id","organization_id","owner_department_id","title","description","body_markdown","audience","subjects","attachments","links","recertification_required_at","created_by","created_at","archived_at"]},"JobAidSubject":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"}},"required":["id","slug","name"]},"JobAidLink":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string","description":"Where the link points; always http or https.","example":"https://www.osha.gov/powered-industrial-trucks"},"label":{"type":["string","null"],"description":"Admin-written label shown in place of the URL, or null."},"position":{"type":"integer","description":"Slot in the aid's one authored order, shared with its attachments — a reader's list interleaves files and links by it."}},"required":["id","url","label","position"]},"JobAidList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/JobAid"}}},"required":["items"]},"CreateJobAidRequest":{"type":"object","properties":{"org":{"type":"string","minLength":1,"description":"Organization UUID id or slug the job aid belongs to.","example":"acme-corporation"},"department":{"type":"string","minLength":1,"description":"UUID id or slug of the owning department within the organization; omit for an aid owned by the organization directly. Ownership scopes administration and limits reading the aid to the department's role holders."},"title":{"type":"string","minLength":1,"maxLength":200,"example":"Forklift pre-start checklist"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no summary."},"body_markdown":{"type":"string","maxLength":200000,"description":"Omit or send empty for no body; files are attached afterwards."},"audience":{"type":"string","enum":["trainees","trainers"],"description":"Defaults to trainees: open to everyone the aid's ownership admits."},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Subjects of the aid's organization to tag it with. Omit for an untagged aid."}},"required":["org","title"]},"ReplaceRecertifiableAttachmentFileRequest":{"type":"object","properties":{"pathname":{"type":"string","minLength":1,"description":"The pathname an upload ticket was issued for, after PUTting the new file there."},"file_name":{"type":"string","minLength":1,"maxLength":300},"requires_recertification":{"type":"boolean","description":"True when the new file must be read again by everyone — a compliance change, say: the parent's recertification cutoff moves to now, so opens recorded before the replacement stop satisfying its proof rule. False for a minor fix — a typo, a stale date — that leaves every earlier open standing."}},"required":["pathname","file_name","requires_recertification"]},"CreateJobAidLinkRequest":{"type":"object","properties":{"url":{"type":"string","minLength":1,"maxLength":2000,"format":"uri","description":"Where the link points; http and https only.","example":"https://www.osha.gov/powered-industrial-trucks"},"label":{"type":"string","maxLength":200,"description":"Omit or send empty to show the URL itself."}},"required":["url"]},"UpdateJobAidLinkRequest":{"type":"object","properties":{"url":{"type":"string","minLength":1,"maxLength":2000,"format":"uri","description":"Where the link points; http and https only.","example":"https://www.osha.gov/powered-industrial-trucks"},"label":{"type":["string","null"],"maxLength":200,"description":"Send null or empty to clear the label back to the URL."},"position":{"type":"integer","minimum":0,"description":"New slot in the authored order the aid's files share."}}},"SetJobAidSubjectsRequest":{"type":"object","properties":{"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Replaces the job aid's subject-matter tags."}},"required":["subject_matter_ids"]},"JobAidView":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"job_aid_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"attachment_id":{"type":["string","null"],"format":"uuid","description":"The file that was opened, or null for an open of the aid's page itself. Only page opens satisfy job_aid_viewed rules."},"viewed_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["id","job_aid_id","user_id","attachment_id","viewed_at"]},"JobAidAnalytics":{"type":"object","properties":{"job_aid_id":{"type":"string","format":"uuid"},"view_count":{"type":"integer","description":"Every recorded open, page and file opens alike, by anyone — administrators previewing included."},"page_view_count":{"type":"integer","description":"Opens of the aid's page alone — what the job_aid_viewed rule counts."},"unique_viewers":{"type":"integer"},"last_viewed_at":{"type":["string","null"],"format":"date-time"},"viewers":{"type":"array","items":{"$ref":"#/components/schemas/JobAidViewer"},"description":"Everyone who has opened the aid, most recent first."}},"required":["job_aid_id","view_count","page_view_count","unique_viewers","last_viewed_at","viewers"]},"JobAidViewer":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid"},"display_name":{"type":["string","null"]},"email":{"type":["string","null"]},"view_count":{"type":"integer","description":"Every open by this person, page and file opens alike."},"first_viewed_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"last_viewed_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["user_id","display_name","email","view_count","first_viewed_at","last_viewed_at"]},"DuplicateJobAidRequest":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"The copy's title; omit for the original's with \" (copy)\" appended."}}},"UpdateJobAidRequest":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null or empty to clear the summary."},"body_markdown":{"type":["string","null"],"maxLength":200000,"description":"Send null or empty to clear the body."},"audience":{"type":"string","enum":["trainees","trainers"],"description":"Whom the job aid is for. trainees: everyone its ownership admits — every member for an organization-owned aid, the owning department's role holders for a department-owned one — trainers included. trainers: only holders of job-aids:read over the aid's owner, for facilitator guides and the like; the aid is absent from everyone else's list."},"archived":{"type":"boolean","description":"True archives the job aid (hidden from its audience); false restores it."},"require_recertification":{"type":"boolean","description":"True moves the aid's recertification cutoff to now: opens recorded before this edit stop satisfying job_aid_viewed rules, so everyone certified on the aid must read it again. For a rewrite that changes what readers must know, not for a typo. Omit or send false to leave every earlier open standing."}}},"QrCodeList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/QrCode"}}},"required":["items"]},"QrCode":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Owning department within the organization; null = owned by the organization directly. Scopes which qr-codes:read/write grants cover the code and, for a url code, who may follow it: every member for an organization-owned code, the owning department's role holders for a department-owned one."},"slug":{"type":"string","example":"bay-3-fume-hood"},"name":{"type":"string","description":"What the code is for, as administrators see it; never shown to scanners."},"description":{"type":["string","null"],"description":"Where the code is posted, or any other note for administrators."},"target":{"$ref":"#/components/schemas/QrCodeTarget"},"created_by":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"updated_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"An archived code answers unavailable to every scan; its history stays."}},"required":["id","organization_id","owner_department_id","slug","name","description","target","created_by","created_at","updated_at","archived_at"]},"QrCodeTarget":{"type":"object","properties":{"kind":{"type":"string","enum":["job_aid","scorm_course","quiz","url"],"description":"What the code points at: one of this deployment's job aids, hosted SCORM courses or quizzes — reached on the learner's own page for it — or a raw http(s) URL anywhere."},"id":{"type":["string","null"],"format":"uuid","description":"The job aid, course or quiz the code points at; null for a url code, and null again once that resource has been permanently deleted."},"url":{"type":["string","null"],"description":"The raw destination of a url code; null for every other kind.","example":"https://example.com/safety-notice"},"label":{"type":["string","null"],"description":"The resource's title or name — as it is now for a code's current target, as it was at the time for a change's snapshot — or null for a url code."},"status":{"type":"string","enum":["live","archived","unpublished","missing"],"description":"live: scanning can reach it (a url always is). archived: the resource is archived, so scans answer unavailable. unpublished: a draft quiz nobody can take yet. missing: the resource was permanently deleted; retarget the code."}},"required":["kind","id","url","label","status"]},"CreateQrCodeRequest":{"type":"object","properties":{"org":{"type":"string","minLength":1,"description":"Organization UUID id or slug the code belongs to.","example":"acme-corporation"},"department":{"type":"string","minLength":1,"description":"UUID id or slug of the owning department within the organization; omit for a code owned by the organization directly. Ownership scopes administration and, for a url code, limits following it to the department's role holders."},"slug":{"type":"string","minLength":2,"maxLength":128,"pattern":"^[a-z0-9][a-z0-9_-]{1,127}$","description":"Omit to have one generated. Choose it before printing: it cannot be changed after.","example":"bay-3-fume-hood"},"name":{"type":"string","minLength":1,"maxLength":200,"example":"Bay 3 fume hood poster"},"description":{"type":"string","maxLength":2000,"description":"Omit or send empty for no note."},"target_kind":{"type":"string","enum":["job_aid","scorm_course","quiz","url"],"description":"What the code points at: one of this deployment's job aids, hosted SCORM courses or quizzes — reached on the learner's own page for it — or a raw http(s) URL anywhere."},"target_id":{"type":"string","format":"uuid","description":"For a job_aid, scorm_course or quiz code: the id of that resource, which must belong to the code's organization. Omit for a url code."},"target_url":{"type":"string","minLength":1,"maxLength":2000,"format":"uri","description":"For a url code: where it points. Omit for every other kind.","example":"https://example.com/safety-notice"}},"required":["org","name","target_kind"]},"RetargetQrCodeRequest":{"type":"object","properties":{"target_kind":{"type":"string","enum":["job_aid","scorm_course","quiz","url"],"description":"What the code points at: one of this deployment's job aids, hosted SCORM courses or quizzes — reached on the learner's own page for it — or a raw http(s) URL anywhere."},"target_id":{"type":"string","format":"uuid","description":"For a job_aid, scorm_course or quiz code: the id of that resource, which must belong to the code's organization. Omit for a url code."},"target_url":{"type":"string","minLength":1,"maxLength":2000,"format":"uri","description":"For a url code: where it points. Omit for every other kind.","example":"https://example.com/safety-notice"}},"required":["target_kind"]},"QrCodeTargetChangeList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/QrCodeTargetChange"}}},"required":["items"]},"QrCodeTargetChange":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"changed_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"changed_by_user_id":{"type":["string","null"],"format":"uuid","description":"Who retargeted the code; null once that account is gone."},"changed_by_display_name":{"type":["string","null"]},"changed_by_email":{"type":["string","null"]},"from":{"$ref":"#/components/schemas/QrCodeTargetRef"},"to":{"allOf":[{"$ref":"#/components/schemas/QrCodeTargetRef"},{"description":"The target after the change, labelled as it was then."}]}},"required":["id","changed_at","changed_by_user_id","changed_by_display_name","changed_by_email","from","to"]},"QrCodeTargetRef":{"type":"object","properties":{"kind":{"type":"string","enum":["job_aid","scorm_course","quiz","url"],"description":"What the code points at: one of this deployment's job aids, hosted SCORM courses or quizzes — reached on the learner's own page for it — or a raw http(s) URL anywhere."},"id":{"type":["string","null"],"format":"uuid","description":"The job aid, course or quiz the code points at; null for a url code, and null again once that resource has been permanently deleted."},"url":{"type":["string","null"],"description":"The raw destination of a url code; null for every other kind.","example":"https://example.com/safety-notice"},"label":{"type":["string","null"],"description":"The resource's title or name — as it is now for a code's current target, as it was at the time for a change's snapshot — or null for a url code."}},"required":["kind","id","url","label"],"description":"The target before the change, labelled as it was then."},"QrCodeAnalytics":{"type":"object","properties":{"qr_code_id":{"type":"string","format":"uuid"},"scan_count":{"type":"integer","description":"Every recorded scan, by anyone — administrators trying the code included."},"redirected_count":{"type":"integer","description":"Scans that were sent on to the destination."},"denied_count":{"type":"integer","description":"Scans by people the target does not reach — a code posted where its audience is not."},"unavailable_count":{"type":"integer","description":"Scans while the code was archived or its target gone, archived or unpublished — a code that needs retargeting or taking down."},"unique_scanners":{"type":"integer"},"last_scanned_at":{"type":["string","null"],"format":"date-time"},"scanners":{"type":"array","items":{"$ref":"#/components/schemas/QrCodeScanner"},"description":"Everyone who has scanned the code, most recent first."}},"required":["qr_code_id","scan_count","redirected_count","denied_count","unavailable_count","unique_scanners","last_scanned_at","scanners"]},"QrCodeScanner":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid"},"display_name":{"type":["string","null"]},"email":{"type":["string","null"]},"scan_count":{"type":"integer","description":"Every scan by this person, whatever came of it."},"redirected_count":{"type":"integer","description":"The scans that sent this person on to the destination."},"first_scanned_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"last_scanned_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["user_id","display_name","email","scan_count","redirected_count","first_scanned_at","last_scanned_at"]},"UpdateQrCodeRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000,"description":"Send null or empty to clear the note."},"archived":{"type":"boolean","description":"True archives the code (every scan answers unavailable); false restores it. Nothing about a printed code changes."}}},"MyTrainingEventList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/MyTrainingEvent"}}},"required":["items"]},"MyTrainingEvent":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Owning department within the organization; null = owned by the organization directly. Scopes which events:read/events:write grants cover the event."},"title":{"type":"string"},"description_markdown":{"type":["string","null"]},"starts_at":{"type":["string","null"],"format":"date-time","description":"When the event starts; null while the time is not yet known (then ends_at is null too)."},"ends_at":{"type":["string","null"],"format":"date-time","description":"When the event ends; null while the time is not yet known."},"meeting_url":{"type":["string","null"]},"location":{"type":["string","null"]},"credit_hours":{"type":["number","null"],"description":"Hours an attendance is worth; null = the event's wall-clock duration is credited."},"created_by":{"type":["string","null"]},"cancelled_at":{"type":["string","null"],"format":"date-time","description":"Set when the event was cancelled; final. A cancelled event takes no attendance."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"An archived event is hidden from listings; its roster stays on record."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventSubject"}},"role":{"type":"string","enum":["participant","trainer","both"]},"attendance":{"type":["string","null"],"enum":["registered","attended","absent",null],"description":"registered: on the roster. attended: was there — a completed learning record was written for them. absent: was not there."},"learning_record_id":{"type":["string","null"],"format":"uuid"}},"required":["id","owner_organization_id","owner_department_id","title","description_markdown","starts_at","ends_at","meeting_url","location","credit_hours","created_by","cancelled_at","created_at","archived_at","subjects","role","attendance","learning_record_id"]},"TrainingEventSubject":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"}},"required":["id","slug","name"]},"TrainingEventList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEvent"}}},"required":["items"]},"TrainingEvent":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Owning department within the organization; null = owned by the organization directly. Scopes which events:read/events:write grants cover the event."},"title":{"type":"string"},"description_markdown":{"type":["string","null"]},"starts_at":{"type":["string","null"],"format":"date-time","description":"When the event starts; null while the time is not yet known (then ends_at is null too)."},"ends_at":{"type":["string","null"],"format":"date-time","description":"When the event ends; null while the time is not yet known."},"meeting_url":{"type":["string","null"]},"location":{"type":["string","null"]},"credit_hours":{"type":["number","null"],"description":"Hours an attendance is worth; null = the event's wall-clock duration is credited."},"created_by":{"type":["string","null"]},"cancelled_at":{"type":["string","null"],"format":"date-time","description":"Set when the event was cancelled; final. A cancelled event takes no attendance."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"An archived event is hidden from listings; its roster stays on record."}},"required":["id","owner_organization_id","owner_department_id","title","description_markdown","starts_at","ends_at","meeting_url","location","credit_hours","created_by","cancelled_at","created_at","archived_at"]},"CreateTrainingEventBatchRequest":{"type":"object","properties":{"org":{"type":"string","minLength":1,"description":"Organization UUID id or slug the event belongs to.","example":"acme-corporation"},"department":{"type":"string","minLength":1,"description":"UUID id or slug of the owning department within the organization; omit for an event owned by the organization directly."},"title":{"type":"string","minLength":1,"maxLength":200},"description_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no description."},"meeting_url":{"type":"string","maxLength":2000,"format":"uri","description":"Video meeting link, for remote or hybrid sessions.","example":"https://meet.example.com/abc-defg-hij"},"location":{"type":"string","minLength":1,"maxLength":500,"description":"Free-text venue, for in-person sessions.","example":"Simulator bay 2, Building C"},"credit_hours":{"type":"number","exclusiveMinimum":0,"maximum":10000,"description":"Hours an attendance credits toward subject-hours rules. Omit to credit the event's wall-clock duration instead.","example":8},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Subjects the event (and every attendance record it produces) is filed under. Subjects of the event's own organization only."},"windows":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventWindow"},"minItems":1,"maxItems":200,"description":"One event is created per window, all sharing the fields above. A client scheduling weekly slots expands them into windows before sending."},"trainer_user_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"`users.id` of members of the organization to schedule as trainers of every event in the series."},"participant_user_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":200,"description":"`users.id` of members of the organization to put on every event's roster as `registered`."}},"required":["org","title","windows"]},"TrainingEventWindow":{"type":"object","properties":{"starts_at":{"type":"string","format":"date-time","description":"When this event starts."},"ends_at":{"type":"string","format":"date-time","description":"When this event ends; after the start."}},"required":["starts_at","ends_at"]},"SchedulingConflictList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SchedulingConflict"}}},"required":["items"]},"SchedulingConflict":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid"},"window_index":{"type":"integer","minimum":0,"description":"Index into the request's windows of the window this conflict is with."},"role":{"type":"string","enum":["participant","trainer","both"],"description":"How the person is on the other event: participant, trainer, or both."},"event_id":{"type":["string","null"],"format":"uuid","description":"The other event, or null when the caller's events:read scope does not cover it — the clash is reported, the event is not disclosed."},"title":{"type":["string","null"],"description":"The other event's title; null with event_id."},"starts_at":{"type":"string","format":"date-time","description":"When the other event starts."},"ends_at":{"type":"string","format":"date-time","description":"When the other event ends."}},"required":["user_id","window_index","role","event_id","title","starts_at","ends_at"]},"CheckSchedulingConflictsRequest":{"type":"object","properties":{"user_ids":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1,"maxItems":250,"description":"`users.id` of the people about to be scheduled."},"windows":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventWindow"},"minItems":1,"maxItems":200,"description":"The windows they would be scheduled in — one for a single event, one per event for a series. Conflicts name the window by its index in this array."},"exclude_event_id":{"type":"string","format":"uuid","description":"An event to leave out of the check: the one being edited, whose own roster would otherwise conflict with itself."}},"required":["user_ids","windows"]},"CreateTrainingEventRequest":{"type":"object","properties":{"org":{"type":"string","minLength":1,"description":"Organization UUID id or slug the event belongs to.","example":"acme-corporation"},"department":{"type":"string","minLength":1,"description":"UUID id or slug of the owning department within the organization; omit for an event owned by the organization directly."},"title":{"type":"string","minLength":1,"maxLength":200},"description_markdown":{"type":"string","maxLength":50000,"description":"Omit or send empty for no description."},"meeting_url":{"type":"string","maxLength":2000,"format":"uri","description":"Video meeting link, for remote or hybrid sessions.","example":"https://meet.example.com/abc-defg-hij"},"location":{"type":"string","minLength":1,"maxLength":500,"description":"Free-text venue, for in-person sessions.","example":"Simulator bay 2, Building C"},"credit_hours":{"type":"number","exclusiveMinimum":0,"maximum":10000,"description":"Hours an attendance credits toward subject-hours rules. Omit to credit the event's wall-clock duration instead.","example":8},"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"Subjects the event (and every attendance record it produces) is filed under. Subjects of the event's own organization only."},"starts_at":{"type":"string","format":"date-time","description":"When the event starts. Omit both starts_at and ends_at for an event whose time is not yet known; it is listed as unscheduled until PATCHed with a window."},"ends_at":{"type":"string","format":"date-time","description":"When the event ends; required exactly when starts_at is given."}},"required":["org","title"]},"DuplicateTrainingEventRequest":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"description":"The copy's title; omit to keep the original's."},"starts_at":{"type":"string","format":"date-time","description":"When the copy starts. Omit both starts_at and ends_at for an unscheduled copy, whose time is set later by PATCH /events/{event_id}."},"ends_at":{"type":"string","format":"date-time","description":"When the copy ends; required exactly when starts_at is given."}}},"TrainingEventDetail":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"owner_organization_id":{"type":"string","format":"uuid"},"owner_department_id":{"type":["string","null"],"format":"uuid","description":"Owning department within the organization; null = owned by the organization directly. Scopes which events:read/events:write grants cover the event."},"title":{"type":"string"},"description_markdown":{"type":["string","null"]},"starts_at":{"type":["string","null"],"format":"date-time","description":"When the event starts; null while the time is not yet known (then ends_at is null too)."},"ends_at":{"type":["string","null"],"format":"date-time","description":"When the event ends; null while the time is not yet known."},"meeting_url":{"type":["string","null"]},"location":{"type":["string","null"]},"credit_hours":{"type":["number","null"],"description":"Hours an attendance is worth; null = the event's wall-clock duration is credited."},"created_by":{"type":["string","null"]},"cancelled_at":{"type":["string","null"],"format":"date-time","description":"Set when the event was cancelled; final. A cancelled event takes no attendance."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"An archived event is hidden from listings; its roster stays on record."},"subjects":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventSubject"}},"trainers":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventTrainer"}},"participants":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventParticipant"}}},"required":["id","owner_organization_id","owner_department_id","title","description_markdown","starts_at","ends_at","meeting_url","location","credit_hours","created_by","cancelled_at","created_at","archived_at","subjects","trainers","participants"]},"TrainingEventTrainer":{"type":"object","properties":{"event_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"]},"user_email":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["event_id","user_id","user_display_name","user_email","created_at"]},"TrainingEventParticipant":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"event_id":{"type":"string","format":"uuid"},"user_id":{"type":"string","format":"uuid"},"user_display_name":{"type":["string","null"]},"user_email":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"attendance":{"type":"string","enum":["registered","attended","absent"],"description":"registered: on the roster. attended: was there — a completed learning record was written for them. absent: was not there."},"attendance_marked_by":{"type":["string","null"]},"attendance_marked_at":{"type":["string","null"],"format":"date-time"},"learning_record_id":{"type":["string","null"],"format":"uuid","description":"The completed learning record the `attended` mark wrote, while it stands."}},"required":["id","event_id","user_id","user_display_name","user_email","created_at","attendance","attendance_marked_by","attendance_marked_at","learning_record_id"]},"UpdateTrainingEventRequest":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200},"description_markdown":{"type":["string","null"],"maxLength":50000,"description":"Send null (or empty) to clear the description."},"starts_at":{"type":["string","null"],"format":"date-time","description":"Send both halves to (re)schedule the event, or null on both to make it unscheduled again. An omitted half keeps its stored value; the result must still be both or neither."},"ends_at":{"type":["string","null"],"format":"date-time","description":"Send null on both halves to clear the window."},"meeting_url":{"type":["string","null"],"maxLength":2000,"format":"uri","description":"Send null to clear.","example":"https://meet.example.com/abc-defg-hij"},"location":{"type":["string","null"],"minLength":1,"maxLength":500,"description":"Send null to clear.","example":"Simulator bay 2, Building C"},"credit_hours":{"type":["number","null"],"exclusiveMinimum":0,"maximum":10000,"description":"Send null to credit the wall-clock duration again.","example":8},"cancelled":{"type":"boolean","enum":[true],"description":"Cancels the event. Final: attendance can no longer be marked, and the learning records earlier attendance marks produced are archived."},"archived":{"type":"boolean","description":"True hides the event from listings; false restores it."}}},"SetTrainingEventSubjectsRequest":{"type":"object","properties":{"subject_matter_ids":{"type":"array","items":{"type":"string","format":"uuid"},"maxItems":50,"description":"The complete set of subject tags; an empty array clears them."}},"required":["subject_matter_ids"]},"TrainingEventParticipantList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventParticipant"}}},"required":["items"]},"AddTrainingEventPersonRequest":{"type":"object","properties":{"user_id":{"type":"string","format":"uuid","description":"`users.id` of a member of the event's organization."}},"required":["user_id"]},"SetTrainingEventAttendanceRequest":{"type":"object","properties":{"attendance":{"type":"string","enum":["registered","attended","absent"],"description":"registered: on the roster. attended: was there — a completed learning record was written for them. absent: was not there."}},"required":["attendance"]},"ForumList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Forum"}}},"required":["items"]},"Forum":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"organization_name":{"type":"string"},"department_id":{"type":"string","format":"uuid","description":"The department the forum belongs to. Everyone holding a role in it, or in a department nested beneath it, may read the forum and take part."},"department_name":{"type":"string"},"name":{"type":"string","example":"Equipment questions"},"description":{"type":["string","null"],"description":"What belongs in the forum, in plain text.","example":"Ask about the bench tools, the hoods and the gauges."},"topic_count":{"type":"integer","minimum":0,"description":"How many discussions it holds."},"last_activity_at":{"type":["string","null"],"format":"date-time","description":"The newest discussion activity in it; null while it holds no discussion."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"archived_at":{"type":["string","null"],"format":"date-time","description":"Set while the forum is archived: its discussions stay readable, but nobody starts one, replies or edits until it is restored."},"viewer":{"$ref":"#/components/schemas/ForumViewer"}},"required":["id","organization_id","organization_name","department_id","department_name","name","description","topic_count","last_activity_at","created_at","archived_at","viewer"]},"ForumViewer":{"type":"object","properties":{"post":{"type":"boolean","description":"Whether the caller may start a discussion here: they take part in the department's forums, and the forum is open."},"manage":{"type":"boolean","description":"Whether the caller may rename, describe, archive and restore it (forums:write)."},"delete":{"type":"boolean","description":"Whether the caller may permanently delete it with every discussion in it (forums:delete)."}},"required":["post","manage","delete"]},"CreateForumRequest":{"type":"object","properties":{"org":{"type":"string","minLength":1,"description":"Organization UUID id or slug the department belongs to.","example":"acme-corporation"},"department":{"type":"string","minLength":1,"description":"UUID id or slug, within the organization, of the department the forum belongs to. Requires forums:write over it.","example":"quality-assurance"},"name":{"type":"string","minLength":1,"maxLength":120,"description":"Unique within the department, ignoring case.","example":"Equipment questions"},"description":{"type":"string","maxLength":1000,"description":"Omit or send empty for no description.","example":"Ask about the bench tools, the hoods and the gauges."}},"required":["org","department","name"]},"ForumTopicList":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ForumTopicSummary"}},"next_cursor":{"type":["string","null"],"description":"Pass as cursor to fetch the next page; null on the last page."}},"required":["items","next_cursor"]},"ForumTopicSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"organization_name":{"type":"string"},"forum_id":{"type":"string","format":"uuid","description":"The forum the discussion is in."},"forum_name":{"type":"string"},"forum_archived_at":{"type":["string","null"],"format":"date-time","description":"Set while the forum is archived: the discussion stays readable, but nobody replies or edits until the forum is restored."},"department_id":{"type":"string","format":"uuid","description":"The forum's department. Everyone holding a role in it, or in a department nested beneath it, may read the discussion and take part."},"department_name":{"type":"string"},"title":{"type":"string"},"author_user_id":{"type":["string","null"],"format":"uuid","description":"The account that wrote it; null once that account has been deleted."},"author_name":{"type":["string","null"],"description":"The author's display name or email; null when unknown or deleted.","example":"Sam Rivera"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"edited_at":{"type":["string","null"],"format":"date-time","description":"When the author last changed the title, body or links; null if never."},"last_activity_at":{"type":"string","format":"date-time","description":"The opening post or the latest reply, whichever is newer."},"pinned_at":{"type":["string","null"],"format":"date-time","description":"Set while a moderator has pinned the discussion to the top of its forum."},"locked_at":{"type":["string","null"],"format":"date-time","description":"Set while a moderator has locked the discussion: only moderators may reply, and nobody may edit."},"reply_count":{"type":"integer","minimum":0}},"required":["id","organization_id","organization_name","forum_id","forum_name","forum_archived_at","department_id","department_name","title","author_user_id","author_name","created_at","edited_at","last_activity_at","pinned_at","locked_at","reply_count"]},"ForumTopic":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organization_id":{"type":"string","format":"uuid"},"organization_name":{"type":"string"},"forum_id":{"type":"string","format":"uuid","description":"The forum the discussion is in."},"forum_name":{"type":"string"},"forum_archived_at":{"type":["string","null"],"format":"date-time","description":"Set while the forum is archived: the discussion stays readable, but nobody replies or edits until the forum is restored."},"department_id":{"type":"string","format":"uuid","description":"The forum's department. Everyone holding a role in it, or in a department nested beneath it, may read the discussion and take part."},"department_name":{"type":"string"},"title":{"type":"string"},"author_user_id":{"type":["string","null"],"format":"uuid","description":"The account that wrote it; null once that account has been deleted."},"author_name":{"type":["string","null"],"description":"The author's display name or email; null when unknown or deleted.","example":"Sam Rivera"},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"edited_at":{"type":["string","null"],"format":"date-time","description":"When the author last changed the title, body or links; null if never."},"last_activity_at":{"type":"string","format":"date-time","description":"The opening post or the latest reply, whichever is newer."},"pinned_at":{"type":["string","null"],"format":"date-time","description":"Set while a moderator has pinned the discussion to the top of its forum."},"locked_at":{"type":["string","null"],"format":"date-time","description":"Set while a moderator has locked the discussion: only moderators may reply, and nobody may edit."},"reply_count":{"type":"integer","minimum":0},"body_markdown":{"type":"string","description":"The opening post, as its author wrote it. Markdown (GitHub-flavoured); clients must render it sanitized — raw HTML and unsafe link protocols are not part of the format."},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ForumResource"},"description":"The linked material the caller may open, in the order the author listed it. A link to something outside the caller's reach is left out, and counted below."},"hidden_resource_count":{"type":"integer","minimum":0,"description":"Linked material the caller cannot open — not in its audience, archived, or its feature switched off."},"replies":{"type":"array","items":{"$ref":"#/components/schemas/ForumReply"},"description":"Every reply, oldest first."},"viewer":{"$ref":"#/components/schemas/ForumTopicViewer"}},"required":["id","organization_id","organization_name","forum_id","forum_name","forum_archived_at","department_id","department_name","title","author_user_id","author_name","created_at","edited_at","last_activity_at","pinned_at","locked_at","reply_count","body_markdown","resources","hidden_resource_count","replies","viewer"]},"ForumResource":{"type":"object","properties":{"kind":{"type":"string","enum":["job_aid","scorm_course","quiz","training_event"],"description":"What the link points at: one of the organization's job aids, hosted SCORM courses, quizzes or training events.","example":"job_aid"},"id":{"type":"string","format":"uuid"},"label":{"type":"string","description":"The resource's current title or name.","example":"Forklift pre-start checklist"},"href":{"type":"string","description":"The page of this deployment that opens the resource for the caller: the learner's own page for a job aid, course or assigned quiz, the administration page for a quiz reached through quizzes:read, the event's page for an event.","example":"/my-job-aids/0b6f3c2e-6a51-4c55-9e8a-2f7d9b1c4a10"}},"required":["kind","id","label","href"]},"ForumReply":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"topic_id":{"type":"string","format":"uuid"},"author_user_id":{"type":["string","null"],"format":"uuid","description":"The account that wrote it; null once that account has been deleted."},"author_name":{"type":["string","null"],"description":"The author's display name or email; null when unknown or deleted.","example":"Sam Rivera"},"body_markdown":{"type":"string","description":"The reply as its author wrote it; render it sanitized, like the opening post."},"created_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"edited_at":{"type":["string","null"],"format":"date-time","description":"When the author last changed the reply; null if never."},"viewer":{"$ref":"#/components/schemas/ForumReplyViewer"}},"required":["id","topic_id","author_user_id","author_name","body_markdown","created_at","edited_at","viewer"]},"ForumReplyViewer":{"type":"object","properties":{"edit":{"type":"boolean","description":"Whether the caller may edit the reply: its author, while the discussion is unlocked."},"delete":{"type":"boolean","description":"Whether the caller may delete the reply: its author, or forums:delete over the department."}},"required":["edit","delete"]},"ForumTopicViewer":{"type":"object","properties":{"reply":{"type":"boolean","description":"Whether the caller may reply: they take part in the department's forums, the forum is open, and the discussion is not locked — unless they moderate it."},"edit":{"type":"boolean","description":"Whether the caller may edit the discussion: its author, while it is unlocked and its forum open."},"delete":{"type":"boolean","description":"Whether the caller may delete the discussion: forums:delete over its department, or its author while nobody else has replied."},"moderate":{"type":"boolean","description":"Whether the caller may pin, unpin, lock and unlock it (forums:moderate)."}},"required":["reply","edit","delete","moderate"]},"CreateForumTopicRequest":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"Which gloves for the new degreaser?"},"body_markdown":{"type":"string","minLength":1,"maxLength":20000,"description":"The opening post. Markdown (GitHub-flavoured: tables, task lists, strikethrough). Stored as written and rendered sanitized: raw HTML is dropped, links may only use http, https or mailto, and images show as links.","example":"The **job aid** says nitrile, the SDS says butyl. Which is right?"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ForumResourceRef"},"maxItems":10,"description":"Up to 10 of the organization's live job aids, courses, quizzes and events to link, in order — each one the caller can open themselves."}},"required":["title","body_markdown"]},"ForumResourceRef":{"type":"object","properties":{"kind":{"type":"string","enum":["job_aid","scorm_course","quiz","training_event"],"description":"What the link points at: one of the organization's job aids, hosted SCORM courses, quizzes or training events.","example":"job_aid"},"id":{"type":"string","format":"uuid"}},"required":["kind","id"]},"UpdateForumRequest":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":120,"description":"Unique within the department, ignoring case.","example":"Equipment questions"},"description":{"type":["string","null"],"maxLength":1000,"description":"Send null or empty to clear the description.","example":"Ask about the bench tools, the hoods and the gauges."},"archived":{"type":"boolean","description":"True archives the forum — its discussions stay readable, but nobody posts, replies or edits; false restores it."}}},"ForumTopicModerationRequest":{"type":"object","properties":{"pinned":{"type":"boolean","description":"True pins the discussion to the top of its board; false unpins it."},"locked":{"type":"boolean","description":"True locks the discussion — only moderators may reply, nobody may edit; false unlocks it."}}},"CreateForumReplyRequest":{"type":"object","properties":{"body_markdown":{"type":"string","minLength":1,"maxLength":10000,"description":"The reply. Markdown (GitHub-flavoured: tables, task lists, strikethrough). Stored as written and rendered sanitized: raw HTML is dropped, links may only use http, https or mailto, and images show as links.","example":"Butyl — the SDS wins. I've asked for the job aid to be corrected."}},"required":["body_markdown"]},"UpdateForumTopicRequest":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":200,"example":"Which gloves for the new degreaser?"},"body_markdown":{"type":"string","minLength":1,"maxLength":20000,"description":"The opening post. Markdown (GitHub-flavoured: tables, task lists, strikethrough). Stored as written and rendered sanitized: raw HTML is dropped, links may only use http, https or mailto, and images show as links.","example":"The **job aid** says nitrile, the SDS says butyl. Which is right?"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ForumResourceRef"},"maxItems":10,"description":"The links, replacing the current ones. Links to material the caller cannot open themselves are kept whatever is sent, since they cannot see them to decide."}}},"UpdateForumReplyRequest":{"type":"object","properties":{"body_markdown":{"type":"string","minLength":1,"maxLength":10000,"description":"The reply. Markdown (GitHub-flavoured: tables, task lists, strikethrough). Stored as written and rendered sanitized: raw HTML is dropped, links may only use http, https or mailto, and images show as links.","example":"Butyl — the SDS wins. I've asked for the job aid to be corrected."}},"required":["body_markdown"]},"CertificationNotificationResult":{"type":"object","properties":{"outcome":{"type":"string","enum":["sent","dry_run","skipped_empty"],"description":"`sent`: the mail server accepted the email. `dry_run`: built and validated, not sent. `skipped_empty`: nothing falls in a window, no training event is upcoming, nothing awaits a person (a submission sent back or pending, an open request), and include_empty was false."},"audience":{"type":"string","enum":["trainee","manager"],"description":"Whose deadlines the digest covers: `trainee` — the user's own certification requirements; `manager` — the requirements of every trainee holding a role the user manages (scoped to those managed roles).","example":"trainee"},"user_id":{"type":"string","format":"uuid","description":"Who the digest covers."},"to":{"type":["string","null"],"description":"The recipient address used, or null when the digest was skipped."},"subject":{"type":["string","null"],"description":"The email's subject line, or null when the digest was skipped."},"total_items":{"type":"integer","minimum":0,"description":"Requirements in the digest across all windows."},"window_counts":{"type":"object","properties":{"overdue":{"type":"integer","minimum":0},"next_10_days":{"type":"integer","minimum":0},"next_30_days":{"type":"integer","minimum":0},"next_60_days":{"type":"integer","minimum":0}},"required":["overdue","next_10_days","next_30_days","next_60_days"],"description":"Requirements per window: overdue, and due in 10/30/60 days."},"upcoming_events":{"type":"integer","minimum":0,"description":"Training events in the digest starting within the next 60 days."},"preview_text":{"type":["string","null"],"description":"The plaintext body that was (or, for dry_run, would have been) sent; the HTML variant is rendered from the same digest."}},"required":["outcome","audience","user_id","to","subject","total_items","window_counts","upcoming_events","preview_text"]},"SendCertificationNotificationRequest":{"type":"object","properties":{"user":{"type":"string","format":"uuid","description":"`users.id` of the user the digest is about — and, unless `email` overrides it, the recipient (resolved from their account's profile).","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"audience":{"type":"string","enum":["trainee","manager"],"description":"Whose deadlines the digest covers: `trainee` — the user's own certification requirements; `manager` — the requirements of every trainee holding a role the user manages (scoped to those managed roles).","example":"trainee"},"email":{"type":"string","format":"email","description":"Send to this address instead of the user's account email.","example":"someone@example.com"},"dry_run":{"type":"boolean","description":"Build, render and validate the email without contacting the mail server. Defaults to false."},"include_empty":{"type":"boolean","description":"Send an \"all clear\" email even when no requirement is overdue or due within 60 days and no training event is upcoming. Defaults to false (such digests are skipped)."}},"required":["user","audience"]},"NotificationSchedule":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the cron tick sends anything."},"cadence":{"type":"string","enum":["daily","weekly"],"description":"`daily`: every day at the hour. `weekly`: on `weekday` only.","example":"weekly"},"weekday":{"type":"integer","minimum":0,"maximum":6,"description":"Day of the week, 0 = Sunday … 6 = Saturday; used when weekly.","example":1},"hour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour of the day, 0–23, on the wall clock of `timezone`.","example":7},"timezone":{"type":"string","minLength":1,"maxLength":64,"description":"IANA timezone the hour is read in.","example":"America/New_York"},"next_due_at":{"type":["string","null"],"format":"date-time","example":"2026-01-01T00:00:00.000Z","description":"The next occurrence the schedule will claim, or null while disabled. The hourly tick sends within the hour that follows it."},"last_due_at":{"type":["string","null"],"format":"date-time","example":"2026-01-01T00:00:00.000Z","description":"The most recent occurrence a tick claimed, or null if none has run yet."},"updated_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"recent_runs":{"type":"array","items":{"$ref":"#/components/schemas/NotificationRun"},"description":"The latest runs, newest first."}},"required":["enabled","cadence","weekday","hour","timezone","next_due_at","last_due_at","updated_at","recent_runs"]},"NotificationRun":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"trigger":{"type":"string","enum":["scheduled","manual"],"description":"`scheduled`: claimed by the cron tick. `manual`: a superuser's run-now."},"due_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z","description":"The scheduled occurrence the run is for; the request time of a manual run."},"status":{"type":"string","enum":["running","completed"],"description":"`running` until every recipient has been processed — a large batch spans several ticks — then `completed`."},"started_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"finished_at":{"type":["string","null"],"format":"date-time","example":"2026-01-01T00:00:00.000Z"},"sent_count":{"type":"integer","minimum":0},"skipped_count":{"type":"integer","minimum":0,"description":"Recipients with nothing due and no upcoming event, or with no email address."},"failed_count":{"type":"integer","minimum":0,"description":"Recipients whose email the mail server refused or that failed validation."}},"required":["id","trigger","due_at","status","started_at","finished_at","sent_count","skipped_count","failed_count"]},"UpdateNotificationScheduleRequest":{"type":"object","properties":{"enabled":{"type":"boolean"},"cadence":{"type":"string","enum":["daily","weekly"],"description":"`daily`: every day at the hour. `weekly`: on `weekday` only.","example":"weekly"},"weekday":{"type":"integer","minimum":0,"maximum":6,"description":"Day of the week, 0 = Sunday … 6 = Saturday; used when weekly.","example":1},"hour":{"type":"integer","minimum":0,"maximum":23,"description":"Hour of the day, 0–23, on the wall clock of `timezone`.","example":7},"timezone":{"type":"string","minLength":1,"maxLength":64,"description":"IANA timezone the hour is read in.","example":"America/New_York"}},"description":"Fields to change; an omitted field is left as it is."},"NotificationTickResult":{"type":"object","properties":{"outcome":{"type":"string","enum":["disabled","nothing_due","busy","ran"],"description":"`disabled`: the schedule is off. `nothing_due`: no occurrence since the last claimed one and no run left to resume. `busy`: another invocation is still working on a run, so nothing was started (a due occurrence waits for the next tick). `ran`: a run was started or resumed (see `run`)."},"run":{"allOf":[{"$ref":"#/components/schemas/NotificationRun"},{"type":["object","null"]}]},"processed":{"type":"integer","minimum":0,"description":"Recipients handled by this invocation."}},"required":["outcome","run","processed"]},"DeploymentBranding":{"type":"object","properties":{"display_name":{"type":["string","null"],"description":"The long-form deployment name shown in the footer, page titles and emails, or null when the short name (or, failing that, the Botree default) is shown.","example":"Acme Corporation Learning Record Storage"},"short_name":{"type":["string","null"],"description":"The short name shown as the dashboard wordmark, and as the top bar title on narrow screens, or null when the long-form name (or, failing that, the Botree default) is shown.","example":"Acme Corp LRS"},"logo":{"$ref":"#/components/schemas/DeploymentBrandingLogo"},"support_email":{"type":["string","null"],"description":"The address the public /support pages tell people to write to for help, or null when the default, support@botreeinc.com, is shown.","example":"lrs-help@acme.com"},"updated_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"}},"required":["display_name","short_name","logo","support_email","updated_at"]},"DeploymentBrandingLogo":{"type":["object","null"],"properties":{"file_name":{"type":"string","description":"The uploaded file's original name."},"content_type":{"type":"string","description":"The image's content type."},"url":{"type":"string","description":"Where the logo is served from, with a cache-busting version query. Public: the header and footer load it as a plain image, and the browser as the favicon. Append `&size=<pixels>` for a smaller rendition of a raster logo.","example":"/api/branding/logo?v=1735689600000"}},"required":["file_name","content_type","url"],"description":"The uploaded logo, or null when the Botree artwork is shown."},"UpdateDeploymentBrandingRequest":{"type":"object","properties":{"display_name":{"type":["string","null"],"maxLength":100,"description":"The long-form name to show; send null or an empty string to restore the default. Omit to leave it unchanged.","example":"Acme Corporation Learning Record Storage"},"short_name":{"type":["string","null"],"maxLength":40,"description":"The short name for the dashboard wordmark and the top bar on narrow screens; send null or an empty string to fall back to the long-form name. Omit to leave it unchanged.","example":"Acme Corp LRS"},"support_email":{"type":["string","null"],"maxLength":254,"description":"The address the public /support pages tell people to write to for help; stored lowercased. Send null or an empty string to restore the default, support@botreeinc.com. Omit to leave it unchanged.","example":"lrs-help@acme.com"}}},"DeploymentBrandingLogoForm":{"type":"object","properties":{"file":{"type":"string","format":"binary","description":"The logo image, as a file part: PNG, JPEG, WebP, GIF or SVG, at most 2 MB. Square artwork fits the header best."}},"required":["file"]},"FeatureFlagList":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/FeatureFlag"},"description":"Every feature of the catalogue, in the order the admin page lists them."}},"required":["features"]},"FeatureFlag":{"type":"object","properties":{"feature":{"type":"string","enum":["quizzes","scorm_courses","job_aids","training_events","learning_pathways","qr_codes","external_lms","support_page","forums"],"description":"A feature of the deployment's catalogue, as listed by `GET /api/feature-flags`.","example":"quizzes"},"label":{"type":"string","description":"The feature's name, as the admin page shows it.","example":"Quizzes"},"description":{"type":"string","description":"What the feature covers.","example":"Quizzes and question banks: assigning them, live sittings, attempts and grading."},"state":{"type":"string","enum":["enabled","hidden","disabled"],"description":"`enabled`: shown and working everywhere. `hidden`: left out of the sidebar and the dashboards, but its pages and endpoints keep working for anyone with the address. `disabled`: off — its pages answer 404, its endpoints answer 404 with the error `feature_disabled`, and it is left out of navigation, search and the calendar. Nothing is deleted; switching it back on brings everything back.","example":"disabled"},"default_state":{"type":"string","enum":["enabled","hidden","disabled"],"description":"The state the feature has until a superuser sets one: `enabled` for most features, `disabled` for those a deployment switches on deliberately (the forums).","example":"enabled"},"updated_at":{"type":["string","null"],"format":"date-time","description":"When a superuser last changed the state, or null while it has never been set.","example":"2026-01-01T00:00:00.000Z"}},"required":["feature","label","description","state","default_state","updated_at"]},"UpdateFeatureFlagsRequest":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/FeatureFlagChange"},"minItems":1,"maxItems":9,"description":"The features to set, each named at most once. A feature left out keeps its state."}},"required":["features"]},"FeatureFlagChange":{"type":"object","properties":{"feature":{"type":"string","enum":["quizzes","scorm_courses","job_aids","training_events","learning_pathways","qr_codes","external_lms","support_page","forums"],"description":"A feature of the deployment's catalogue, as listed by `GET /api/feature-flags`.","example":"quizzes"},"state":{"type":"string","enum":["enabled","hidden","disabled"],"description":"`enabled`: shown and working everywhere. `hidden`: left out of the sidebar and the dashboards, but its pages and endpoints keep working for anyone with the address. `disabled`: off — its pages answer 404, its endpoints answer 404 with the error `feature_disabled`, and it is left out of navigation, search and the calendar. Nothing is deleted; switching it back on brings everything back.","example":"disabled"}},"required":["feature","state"]},"SearchResults":{"type":"object","properties":{"query":{"type":"string","description":"The query as searched (trimmed).","example":"welding"},"mode":{"type":"string","enum":["keyword","semantic","combined"],"description":"How the groups were matched."},"notice":{"type":["string","null"],"description":"Why the answer is less than the mode promised — the AI matches missing because the embedding provider did not answer — or null.","example":null},"groups":{"type":"array","items":{"$ref":"#/components/schemas/SearchResultGroup"},"description":"Every entity group in a fixed order, empty ones included — less the groups of any feature the deployment has switched off (`GET /api/feature-flags`)."}},"required":["query","mode","notice","groups"]},"SearchResultGroup":{"type":"object","properties":{"title":{"type":"string","example":"Learning records"},"hits":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"truncated":{"type":"boolean","description":"True when more rows matched than the group carries (five)."},"view_all_href":{"type":"string","description":"The list page a truncated group points onward to.","example":"/records?q=welding"}},"required":["title","hits","truncated","view_all_href"]},"SearchHit":{"type":"object","properties":{"key":{"type":"string","description":"Stable within its group.","example":"6f3b34d8-…"},"name":{"type":"string","example":"Welding safety induction"},"href":{"type":"string","description":"The page the hit opens — always one its viewer may open.","example":"/records/6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"context":{"type":["string","null"],"description":"Secondary line: owner, email, learner, or a description snippet.","example":"Jordan Lee"},"badges":{"type":"array","items":{"type":"string"},"description":"Lifecycle chips such as \"Draft\" or \"Archived\"; empty for active rows.","example":[]}},"required":["key","name","href","context","badges"]},"AiSearchStatus":{"type":"object","properties":{"configuration":{"$ref":"#/components/schemas/AiSearchConfiguration"},"counts":{"type":"array","items":{"$ref":"#/components/schemas/AiSearchIndexCount"},"description":"Chunks of the current model, by the entity they answer for and their modality."},"stale_chunks":{"type":"integer","minimum":0,"description":"Chunks embedded by another provider or model; a full run removes them."},"last_completed_at":{"type":["string","null"],"format":"date-time","example":"2026-01-01T00:00:00.000Z"},"recent_runs":{"type":"array","items":{"$ref":"#/components/schemas/SearchIndexRun"},"description":"Newest first."}},"required":["configuration","counts","stale_chunks","last_completed_at","recent_runs"]},"AiSearchConfiguration":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether `AI_SEARCH_EMBEDDING_PROVIDER` names a provider."},"embedding_provider":{"type":["string","null"],"enum":["openai","voyage","gemini","local",null]},"embedding_model":{"type":["string","null"],"example":"text-embedding-3-small"},"embedding_dimensions":{"type":["integer","null"],"exclusiveMinimum":0,"description":"The vector length, once known; discovered from the first embedding."},"embedding_base_url":{"type":["string","null"],"description":"The OpenAI-compatible endpoint in use, when not the provider's own."},"embeds_images_natively":{"type":"boolean","description":"Whether the embedding model takes images itself (multimodal)."},"media_provider":{"type":"string","enum":["none","openai","gemini","anthropic"]},"media_model":{"type":["string","null"]},"max_file_bytes":{"type":"integer","exclusiveMinimum":0,"description":"Files larger than this are indexed by their name alone."},"problems":{"type":"array","items":{"type":"string"},"description":"Configuration the server could not use, as it would be logged."}},"required":["enabled","embedding_provider","embedding_model","embedding_dimensions","embedding_base_url","embeds_images_natively","media_provider","media_model","max_file_bytes","problems"]},"AiSearchIndexCount":{"type":"object","properties":{"entity_kind":{"type":"string","example":"job_aid"},"modality":{"type":"string","enum":["text","pdf","image","video"]},"sources":{"type":"integer","minimum":0,"description":"Distinct source rows."},"chunks":{"type":"integer","minimum":0}},"required":["entity_kind","modality","sources","chunks"]},"SearchIndexRun":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"trigger":{"type":"string","enum":["scheduled","manual"],"description":"`scheduled`: the hourly cron tick. `manual`: a superuser's button."},"mode":{"type":"string","enum":["incremental","full"],"description":"`incremental`: only sources changed since the previous completed run. `full`: every source, re-embedding what the current model has not embedded yet."},"status":{"type":"string","enum":["running","completed","failed"],"description":"`running` until every source has been processed — a large index spans several ticks — then `completed`; `failed` when the provider refused the run outright."},"provider":{"type":"string","example":"openai"},"model":{"type":"string","example":"text-embedding-3-small"},"started_at":{"type":"string","format":"date-time","example":"2026-01-01T00:00:00.000Z"},"finished_at":{"type":["string","null"],"format":"date-time","example":"2026-01-01T00:00:00.000Z"},"indexed_count":{"type":"integer","minimum":0,"description":"Sources embedded."},"skipped_count":{"type":"integer","minimum":0,"description":"Sources left as they were: unchanged, empty, or a file storage cannot serve."},"failed_count":{"type":"integer","minimum":0,"description":"Sources the provider or the file parser could not handle."},"error":{"type":["string","null"],"description":"Why a failed run stopped."}},"required":["id","trigger","mode","status","provider","model","started_at","finished_at","indexed_count","skipped_count","failed_count","error"]},"SearchIndexTickResult":{"type":"object","properties":{"outcome":{"type":"string","enum":["disabled","nothing_due","busy","ran"],"description":"`disabled`: no embedding provider is configured. `nothing_due`: nothing to resume and no pass due. `busy`: another invocation holds the open run. `ran`: a run was started or resumed (see `run`)."},"run":{"allOf":[{"$ref":"#/components/schemas/SearchIndexRun"},{"type":["object","null"]}]},"processed":{"type":"integer","minimum":0,"description":"Sources handled by this invocation."}},"required":["outcome","run","processed"]},"StartSearchIndexRunRequest":{"type":"object","properties":{"mode":{"type":"string","enum":["incremental","full"],"default":"incremental","description":"`incremental` (the default) or `full`."}}}},"parameters":{}},"paths":{"/api/auth/token-info":{"get":{"tags":["auth"],"summary":"Describe the presented access token","description":"Example protected endpoint: echoes back the claims of the bearer token after it has been validated against the OIDC provider.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The validated access token.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenInfo"}}}},"400":{"description":"The Authorization header does not use the Bearer scheme.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token is missing a required scope.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The access token could not be validated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/.well-known/oauth-protected-resource":{"get":{"tags":["auth"],"summary":"Describe this API as an OAuth 2.0 protected resource","description":"OAuth 2.0 Protected Resource Metadata (RFC 9728). Public: it names the authorization servers — the enabled sign-in providers — whose access tokens this API accepts, so a client can discover where to obtain one from the API's URL alone, and names the deployment after its branding. Also served at the root, `/.well-known/oauth-protected-resource`, which is the URL every 401 challenge points at in its `resource_metadata` parameter.","security":[],"responses":{"200":{"description":"The metadata document.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProtectedResourceMetadata"}}}},"500":{"description":"The sign-in providers could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations":{"post":{"tags":["directory"],"summary":"Create an organization","description":"Creates a new top-level organization. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrganizationRequest"}}}},"responses":{"201":{"description":"The created organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"An organization with that slug already exists.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["directory"],"summary":"List organizations","description":"Lists every organization in the directory, archived ones included. Requires a valid access token.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Every organization, ordered by name.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationSummary"}}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}":{"patch":{"tags":["directory"],"summary":"Update an organization","description":"Changes an organization's slug, name or description; omitted fields keep their values. Requires a superuser's access token, like creating one.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrganizationRequest"}}}},"responses":{"200":{"description":"The updated organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No organization has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Another organization already has that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments":{"post":{"tags":["directory"],"summary":"Create a department","description":"Creates a department in an organization, optionally nested under a parent department in the same organization. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the parent — the organization for a top-level department, the parent department for a nested one.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDepartmentRequest"}}}},"responses":{"201":{"description":"The created department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Department"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is neither a superuser nor a directory:write holder whose scope covers the parent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, or the parent department is not in that organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a department with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments/{departmentId}":{"patch":{"tags":["directory"],"summary":"Update a department","description":"Changes a department's slug, name or description, or moves it under another parent in the same organization; omitted fields keep their values. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the department — and, when moving it, the new parent too.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDepartmentRequest"}}}},"responses":{"200":{"description":"The updated department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Department"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is neither a superuser nor a directory:write holder whose scope covers the department (and the new parent, when moving it).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, the department is not in that organization, or the new parent is not in that organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a department with that slug, or the move would nest the department beneath itself.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments/{departmentId}/roles":{"post":{"tags":["directory"],"summary":"Create a department role","description":"Creates a role in a department of an organization, with the permission strings it grants. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the department.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDepartmentRoleRequest"}}}},"responses":{"201":{"description":"The created role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DepartmentRole"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is neither a superuser nor a directory:write holder whose scope covers the department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, or the department is not in that organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The department already has a role with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}":{"patch":{"tags":["directory"],"summary":"Update a department role","description":"Changes a role's slug, name or description, or replaces the permission set it grants; omitted fields keep their values. Requires a superuser's access token, or one whose subject holds the directory:write permission in a scope covering the department.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"4c1d2f6a-8e0b-45c7-9a92-6d84a4f4c9b1"},"required":true,"name":"roleId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDepartmentRoleRequest"}}}},"responses":{"200":{"description":"The updated role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DepartmentRole"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is neither a superuser nor a directory:write holder whose scope covers the department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, the department is not in that organization, or the department has no role with that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The department already has a role with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/permanent-deletion":{"get":{"tags":["directory"],"summary":"Preview permanently deleting a department role","description":"Counts everything DELETE on this path would remove: the people currently holding the role (who lose every permission it grants), every grant of it on record, the reporting lines it sits on either end of, its certification requirements, and the quiz and learning pathway assignments that reached its holders. Nothing is changed. Requires a superuser's access token, or one whose subject holds directory:delete in a scope covering the department — a grant separate from directory:write, which only edits roles.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"4c1d2f6a-8e0b-45c7-9a92-6d84a4f4c9b1"},"required":true,"name":"roleId","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is neither a superuser nor a directory:delete holder whose scope covers the department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, the department is not in that organization, or the department has no role with that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["directory"],"summary":"Permanently delete a department role","description":"Hard-deletes the role and everything that depends on it. Everyone holding it loses it, and with it every permission it granted; every grant of it on record is deleted, so nothing says who ever held it. Its reporting lines, certification requirements, and quiz and learning pathway assignments go with it; certification awards, learning records and attempts already on record stay. Irreversible — roles have no archive. Preview the cost with GET first. Requires a superuser's access token, or one whose subject holds directory:delete in a scope covering the department — a grant separate from directory:write, which only edits roles.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"4c1d2f6a-8e0b-45c7-9a92-6d84a4f4c9b1"},"required":true,"name":"roleId","in":"path"}],"responses":{"204":{"description":"The role and its dependent rows are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is neither a superuser nor a directory:delete holder whose scope covers the department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, the department is not in that organization, or the department has no role with that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/users/import":{"post":{"tags":["directory"],"summary":"Import directory members from a CSV file","description":"Adds every row of an uploaded CSV file to the organization's user directory in one request. The file needs a header row naming a user id, name and email column; rows whose subject is already in the directory are skipped rather than overwritten, so an import can safely be re-run. A file with any invalid row imports nothing and reports the problems, so a corrected file can simply be uploaded again. With `allow_email_only`, rows may name a member by email alone (see the form field). Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/DirectoryImportForm"}}}},"responses":{"200":{"description":"The import's outcome: what was added and what was already present.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryImportSummary"}}}},"400":{"description":"The file is not parseable CSV, a required column is missing from the header, a row failed validation, a subject or email appears twice, an email-only row's address is carried by several live accounts, or the file has no (or too many) data rows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No organization or sign-in provider has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A concurrent sign-in or import raced this one; re-run the import.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"The file is larger than 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/users":{"get":{"tags":["directory"],"summary":"List an organization's user directory","description":"Lists every member of the organization's user directory, named members first, each with the number of roles they currently hold. `trainers=true` narrows the list to the members holding a trainer role — an unexpired grant of a role that manages another role through a reporting line — the people an event's trainer pickers offer. Requires a valid access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","enum":["true","false"],"description":"Set true to list only the members holding a trainer role: an unexpired grant of a role that manages another role through a reporting line. Defaults to false."},"required":false,"description":"Set true to list only the members holding a trainer role: an unexpired grant of a role that manages another role through a reporting line. Defaults to false.","name":"trainers","in":"query"}],"responses":{"200":{"description":"The directory's members.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryUser"}}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No organization has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["directory"],"summary":"Add a user to the directory","description":"Adds a user to the organization's directory: an existing account by user_id, or a pre-provisioned identity by provider_id + subject — the account is created on the spot when that identity is new. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddDirectoryUserRequest"}}}},"responses":{"201":{"description":"The added member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryUser"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, the named user, or the named provider does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That user is already in the organization's directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/users/{user_id}":{"get":{"tags":["directory"],"summary":"Get a directory member","description":"Returns one member of the organization's directory, with every role grant they hold there — expired ones included. Requires a valid access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The member's account id (`users.id`).","name":"user_id","in":"path"}],"responses":{"200":{"description":"The member and their grants.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryUserWithRoles"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, or that user is not in its directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["directory"],"summary":"Remove a user from the directory","description":"Removes a member from the organization's directory, revoking every role they hold there. The account itself (and its history) remains. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The member's account id (`users.id`).","name":"user_id","in":"path"}],"responses":{"200":{"description":"The removed member, with the grants that were revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryUserWithRoles"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, or that user is not in its directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/users/{user_id}/roles":{"get":{"tags":["directory"],"summary":"List a directory member's roles","description":"Lists every role grant a member holds in the organization, expired ones included — check expires_at. Requires a valid access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The member's account id (`users.id`).","name":"user_id","in":"path"}],"responses":{"200":{"description":"The member's grants, by department and role name.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DirectoryUserRole"}}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization does not exist, or that user is not in its directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["directory"],"summary":"Assign a role to a directory member","description":"Grants a member one of the roles defined by a department of the organization, open-ended or until an expiry. Requires a superuser's access token, or one whose subject holds directory:write in a scope covering the role's department — and then only for a member who already holds a role in a department that scope covers.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The member's account id (`users.id`).","name":"user_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignDirectoryUserRoleRequest"}}}},"responses":{"201":{"description":"The created grant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryUserRole"}}}},"400":{"description":"The request body failed validation, or expires_at is not in the future.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is neither a superuser nor holds directory:write over the role's department, or the member holds no role in a department that grant covers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, the member in its directory, the department, or the role does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The member already holds that role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/users/{user_id}/roles/{role_id}":{"patch":{"tags":["directory"],"summary":"Update a directory member's role grant","description":"Changes when a member's grant expires — renewing a lapsed one, scheduling an end, or making it open-ended with null. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The member's account id (`users.id`).","name":"user_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Role UUID id or slug, resolved among the member's grants. A member holding identically-slugged roles in two departments must be addressed by the UUID.","example":"reviewer"},"required":true,"description":"Role UUID id or slug, resolved among the member's grants. A member holding identically-slugged roles in two departments must be addressed by the UUID.","name":"role_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDirectoryUserRoleRequest"}}}},"responses":{"200":{"description":"The updated grant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryUserRole"}}}},"400":{"description":"The request body failed validation, expires_at does not follow the grant time, or the role slug is ambiguous across the member's departments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, the member in its directory, or the member's grant of that role does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["directory"],"summary":"Revoke a directory member's role","description":"Removes one of a member's role grants. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The member's account id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The member's account id (`users.id`).","name":"user_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Role UUID id or slug, resolved among the member's grants. A member holding identically-slugged roles in two departments must be addressed by the UUID.","example":"reviewer"},"required":true,"description":"Role UUID id or slug, resolved among the member's grants. A member holding identically-slugged roles in two departments must be addressed by the UUID.","name":"role_id","in":"path"}],"responses":{"200":{"description":"The revoked grant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DirectoryUserRole"}}}},"400":{"description":"The role slug is ambiguous across the member's departments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, the member in its directory, or the member's grant of that role does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/managers":{"get":{"tags":["directory"],"summary":"List a role's reporting lines","description":"Lists the roles managing this role and the roles it manages. Holders of a manager role (or of any role above it in the chain) may approve or reject the pending learning-record submissions of the subordinate role's holders.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10"},"required":true,"name":"roleId","in":"path"}],"responses":{"200":{"description":"The role's reporting lines, both directions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleManagers"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department, or role does not exist there.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["directory"],"summary":"Make a role a manager of this role","description":"Adds a reporting line: the named role becomes a manager of the role in the path. Both roles must belong to the same organization; a line that would close a cycle is refused. Requires a superuser's access token, or one whose user holds directory:write in a scope covering both roles' departments.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10"},"required":true,"name":"roleId","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoleManagerRequest"}}}},"responses":{"201":{"description":"The created reporting line, manager side resolved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleManagerEdge"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is neither a superuser nor a directory:write holder whose scope covers both roles' departments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department, or role does not exist there, or the manager role is not in that organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The role already reports to that manager, or the line would close a cycle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/managers/{managerRoleId}":{"delete":{"tags":["directory"],"summary":"Remove a manager from this role","description":"Removes the reporting line between this role and the named manager role. Requires a superuser's access token, or one whose user holds directory:write in a scope covering both roles' departments.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10"},"required":true,"name":"roleId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"2e6d0c4a-8b1f-4e7a-9c3d-5a4b6c7d8e9f"},"required":true,"name":"managerRoleId","in":"path"}],"responses":{"204":{"description":"The reporting line is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is neither a superuser nor a directory:write holder whose scope covers both roles' departments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No such reporting line — a path segment or the line itself does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}":{"get":{"tags":["directory"],"summary":"Get an organization","description":"Returns one organization and every department inside it. Requires a valid access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"}],"responses":{"200":{"description":"The organization and its departments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationWithDepartments"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No organization has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/departments/{dept_id}":{"get":{"tags":["directory"],"summary":"Get a department","description":"Returns one department of an organization. Requires a valid access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within the organization.","example":"quality-assurance"},"required":true,"description":"Department UUID id or slug, resolved within the organization.","name":"dept_id","in":"path"}],"responses":{"200":{"description":"The department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DepartmentSummary"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, or the department within it, does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/departments/{dept_id}/roles":{"get":{"tags":["directory"],"summary":"List a department's roles","description":"Lists the roles a department defines, with the permissions each grants and how many users currently hold it. Requires a valid access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within the organization.","example":"quality-assurance"},"required":true,"description":"Department UUID id or slug, resolved within the organization.","name":"dept_id","in":"path"}],"responses":{"200":{"description":"The department's roles, ordered by name.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DepartmentRoleSummary"}}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, or the department within it, does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/departments/{dept_id}/roles/{role_id}":{"get":{"tags":["directory"],"summary":"Get a department role","description":"Returns one role of a department, with the permissions it grants and how many users currently hold it. Requires a valid access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within the organization.","example":"quality-assurance"},"required":true,"description":"Department UUID id or slug, resolved within the organization.","name":"dept_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Role UUID id or slug, resolved within the department.","example":"reviewer"},"required":true,"description":"Role UUID id or slug, resolved within the department.","name":"role_id","in":"path"}],"responses":{"200":{"description":"The role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DepartmentRoleSummary"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department or role does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The directory database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/providers":{"get":{"tags":["providers"],"summary":"List the sign-in providers","description":"Every registered OpenID Connect provider, disabled ones included. Rows name the env vars their secrets live in; the secrets themselves are never served.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The registered providers, oldest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInProviderList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The provider catalogue could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["providers"],"summary":"Register a sign-in provider","description":"Registers an OpenID Connect provider whose sign-ins and access tokens this deployment accepts. Secrets stay in the environment: the row names the variables to read. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSignInProviderRequest"}}}},"responses":{"201":{"description":"The registered provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInProvider"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The slug or issuer is taken.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The provider catalogue could not be written to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/providers/{provider_id}":{"get":{"tags":["providers"],"summary":"Read one sign-in provider","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Provider UUID id or slug.","example":"acme-sso"},"required":true,"description":"Provider UUID id or slug.","name":"provider_id","in":"path"}],"responses":{"200":{"description":"The provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInProvider"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No provider has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The provider catalogue could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["providers"],"summary":"Update a sign-in provider","description":"Changes a provider's settings; omitted fields keep their values. Disabling a provider stops its sign-ins and token verification while keeping its identities. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Provider UUID id or slug.","example":"acme-sso"},"required":true,"description":"Provider UUID id or slug.","name":"provider_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSignInProviderRequest"}}}},"responses":{"200":{"description":"The updated provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInProvider"}}}},"400":{"description":"The request body failed validation, or the settings violate a constraint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No provider has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The slug or issuer is taken.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The provider catalogue could not be written to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["providers"],"summary":"Delete a sign-in provider","description":"Removes a provider nothing references. A provider whose identities still exist cannot be deleted — disable it, or detach the identities first. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Provider UUID id or slug.","example":"acme-sso"},"required":true,"description":"Provider UUID id or slug.","name":"provider_id","in":"path"}],"responses":{"200":{"description":"The deleted provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignInProvider"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No provider has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Sign-in identities still reference the provider.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The provider catalogue could not be written to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/me":{"get":{"tags":["users"],"summary":"Read the caller's account","description":"The account the presented token resolves to: canonical profile and sign-in methods. Accounts come into existence on first contact, so this always answers for a valid token.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The account could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/me/identities/{identity_id}":{"delete":{"tags":["users"],"summary":"Unlink one of the caller's sign-in methods","description":"Detaches a sign-in method from the caller's own account. The last method cannot be unlinked — that would lock the account out; ask a superuser to detach it deliberately.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The identity's id, from the account's identity listing."},"required":true,"description":"The identity's id, from the account's identity listing.","name":"identity_id","in":"path"}],"responses":{"200":{"description":"The unlinked sign-in method.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdentity"}}}},"400":{"description":"The identity is the account's last sign-in method.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The identity does not exist, or belongs to another account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The identity could not be detached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users":{"post":{"tags":["users"],"summary":"Create an account known only by email","description":"Creates a user whose OIDC subject is not yet known, with a pending email link at the named provider: the first sign-in there whose `email` claim matches (subject to the provider's email verification strictness) attaches its identity to this account instead of creating a new one. Refused when the address already has a pending link or is the profile email of a live account — attach the known identity or merge instead. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserRequest"}}}},"responses":{"201":{"description":"The new account and its pending email link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedUser"}}}},"400":{"description":"The request body failed validation, or the email is not an address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No sign-in provider has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That address already has a pending link or belongs to a live account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The account could not be created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/{user_id}/email-links":{"get":{"tags":["users"],"summary":"List an account's email links","description":"Every email link of the account, pending and already claimed, oldest first. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"responses":{"200":{"description":"The account's email links, oldest first.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserEmailLink"}}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No user has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The email links could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["users"],"summary":"Add a pending email link to an account","description":"Names an address whose first sign-in at the given provider should attach its identity to this account (subject to the provider's email verification strictness). Refused when the address already has a pending link or is the profile email of a *different* live account. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEmailLinkRequest"}}}},"responses":{"201":{"description":"The pending email link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserEmailLink"}}}},"400":{"description":"The request body failed validation, or the email is not an address.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No user or provider has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That address already has a pending link or belongs to another account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The email link could not be created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/{user_id}/email-links/{link_id}":{"delete":{"tags":["users"],"summary":"Remove a pending email link","description":"Withdraws a link before any sign-in has claimed it. Links already claimed are the account's history and cannot be removed. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The email link's id, from the account's email link listing."},"required":true,"description":"The email link's id, from the account's email link listing.","name":"link_id","in":"path"}],"responses":{"200":{"description":"The removed email link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserEmailLink"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No pending email link with that id belongs to the user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The email link could not be removed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/{user_id}":{"patch":{"tags":["users"],"summary":"Update an account's profile or standing","description":"Sets the account's display name (null clears it) and/or switches it off and on with `disabled`; omitted fields keep their values. The name is the canonical profile the app shows everywhere, and a later sign-in whose ID token carries a name claim refreshes it again. Disabling is the reversible way to keep someone out: sign-in and tokens are refused and open sessions end, while everything the account owns stays put until it is enabled again (permanent deletion lives at /users/{user_id}/permanent-deletion). A merged (tombstoned) account cannot be edited — its survivor holds the profile — and a caller cannot disable their own account. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}}},"responses":{"200":{"description":"The updated account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserAccount"}}}},"400":{"description":"The request body failed validation, the account was merged away, or the caller tried to disable their own account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No user has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The account could not be updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/{user_id}/identities":{"get":{"tags":["users"],"summary":"List an account's sign-in methods","description":"Every identity attached to the account. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"responses":{"200":{"description":"The account's sign-in methods, oldest first.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/UserIdentity"}}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No user has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The identities could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["users"],"summary":"Attach a sign-in method to an account","description":"Links a (provider, subject) pair to the account — the administrative side of identity linking, for provider migrations and rescues. An identity already attached to a *different* account is refused: merge or detach it first. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachIdentityRequest"}}}},"responses":{"201":{"description":"The attached sign-in method.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdentity"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No user or provider has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That identity already belongs to a different account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The identity could not be attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/{user_id}/identities/{identity_id}":{"delete":{"tags":["users"],"summary":"Detach a sign-in method from an account","description":"Removes one identity from the account. Detaching the *last* one locks the account out of signing in (its history remains) and is refused unless `force=true` — the deliberate offboarding or compromised-credential case. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"The identity's id, from the account's identity listing."},"required":true,"description":"The identity's id, from the account's identity listing.","name":"identity_id","in":"path"},{"schema":{"type":"string","enum":["true","false"],"description":"Set true to detach the account's last sign-in method."},"required":false,"description":"Set true to detach the account's last sign-in method.","name":"force","in":"query"}],"responses":{"200":{"description":"The detached sign-in method.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIdentity"}}}},"400":{"description":"The identity is the account's last sign-in method and force is not set.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The user or the identity does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The identity could not be detached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/{user_id}/merge":{"post":{"tags":["users"],"summary":"Merge an account into another","description":"Absorbs the account at {user_id} into into_user_id: identities move over, every reference is repointed at the survivor (redundant memberships, grants and duplicates resolve in the survivor's favour), and the absorbed account is tombstoned. The fix for the duplicate a first sign-in creates before its identity is linked. One transaction, irreversible. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MergeUsersRequest"}}}},"responses":{"200":{"description":"The merge's outcome.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MergeUsersResponse"}}}},"400":{"description":"The merge is refused: a self-merge, or a tombstoned participant.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"One of the accounts does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The merge failed; nothing was changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/{user_id}/permanent-deletion":{"get":{"tags":["users"],"summary":"Preview permanently deleting an account","description":"Counts everything DELETE on this path would remove: sign-in methods, memberships and role grants, the learning records, certification awards, sign-offs, attempts and roster entries that are the person's, and how many rows created or approved by them stay behind without attribution. Nothing is changed. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No user has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["users"],"summary":"Permanently delete an account","description":"Hard-deletes the account and everything that is the person's — sign-in methods, memberships, role grants, learning records with their evidence files, certification awards, sign-offs given and received, quiz and SCORM attempts, event roster entries, imported grades, job aid views and pending email links — and drops their name from rows they created or approved for others. Duplicates previously merged into the account go with it. Irreversible; disabling the account (PATCH /users/{user_id}) is the reversible alternative. Preview the cost with GET first. A caller cannot delete their own account. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The account's id (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":true,"description":"The account's id (`users.id`).","name":"user_id","in":"path"}],"responses":{"204":{"description":"The account and everything it owned are gone."},"400":{"description":"The caller tried to delete their own account.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No user has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/me/calendar-feeds":{"get":{"tags":["calendar-feeds"],"summary":"List the caller's calendar links","description":"The caller's live calendar subscription links, newest first. The feed URLs themselves are never listed — only the last four characters of each (`token_hint`); a lost URL is replaced by creating a new link and revoking the old one. With include_revoked=true, the links revoked in the last 90 days follow. `can_include_team` says whether the caller may give a link a team scope.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["true","false"],"description":"Set true to add the recently revoked links."},"required":false,"description":"Set true to add the recently revoked links.","name":"include_revoked","in":"query"}],"responses":{"200":{"description":"The caller's calendar links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarFeedTokenList"}}}},"400":{"description":"A query parameter failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The calendar links could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["calendar-feeds"],"summary":"Create a calendar link","description":"Creates a private calendar subscription link for the caller and returns its URL — the only response that ever will. Anyone holding the URL reads the feed (GET /calendar-feeds/{token}/calendar.ics) until the link is revoked, so hand it only to the calendar app subscribing to it. A team scope needs the caller to manage a role. A caller may hold 10 live links at once. A deployment can switch calendar feeds off (CALENDAR_FEEDS_ENABLED=false); no link can be created then.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCalendarFeedTokenRequest"}}}},"responses":{"201":{"description":"The new link, with its URL and the ready-made subscribe links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatedCalendarFeedToken"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"A team scope was asked for and the caller manages no role, or calendar feeds are turned off on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The caller already holds 10 live links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The calendar link could not be created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/me/calendar-feeds/revoke-all":{"post":{"tags":["calendar-feeds"],"summary":"Revoke every calendar link of the caller","description":"Revokes all of the caller's live links at once — for a lost device, or a URL shared somewhere it should not have been when the caller cannot tell which. Every subscribed calendar stops updating; entries already synced stay until removed in each app.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"How many links were revoked; zero when there were none.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevokeAllCalendarFeedTokensResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The calendar links could not be revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/users/me/calendar-feeds/{feed_id}":{"patch":{"tags":["calendar-feeds"],"summary":"Rename or rescope a calendar link","description":"Changes a live link's label or what it serves. The URL stays the same, so the calendar subscribed to it needs nothing redone: it picks the change up on its next fetch — which for Outlook can take hours. Switching to a team scope needs the caller to manage a role. A revoked link cannot be changed.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The link's id, from the caller's link listing."},"required":true,"description":"The link's id, from the caller's link listing.","name":"feed_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCalendarFeedTokenRequest"}}}},"responses":{"200":{"description":"The updated link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarFeedToken"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"A team scope was asked for, and the caller manages no role.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The caller has no live link with that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The calendar link could not be updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["calendar-feeds"],"summary":"Revoke a calendar link","description":"Revokes one of the caller's links: its URL stops serving at once. Entries a calendar app already synced stay there until the subscription is removed in that app. Revoking an already-revoked link changes nothing and answers 200.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"The link's id, from the caller's link listing."},"required":true,"description":"The link's id, from the caller's link listing.","name":"feed_id","in":"path"}],"responses":{"200":{"description":"The revoked link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CalendarFeedToken"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The caller has no link with that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The calendar link could not be revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/calendar-feeds/{token}/calendar.ics":{"get":{"tags":["calendar-feeds"],"summary":"Read a calendar feed","description":"The iCalendar (RFC 5545) feed a calendar app subscribes to: the link owner's certification deadlines, the training events they are on and the live quiz sittings they may sit — and, on a team link of an owner who manages a role, their trainees' due dates (team) or due dates, events and sittings (team_schedule). Evaluated afresh on every fetch. Authorized by the token in the path instead of a bearer token, so anyone holding the URL reads the feed until the link is revoked. HEAD answers the headers without building the feed.","security":[],"parameters":[{"schema":{"type":"string","minLength":1,"description":"The link's secret token, from the feed_url that POST /users/me/calendar-feeds returned when the link was created.","example":"lrscal_…"},"required":true,"description":"The link's secret token, from the feed_url that POST /users/me/calendar-feeds returned when the link was created.","name":"token","in":"path"}],"responses":{"200":{"description":"The feed, as text/calendar.","content":{"text/calendar":{"schema":{"type":"string"}}}},"404":{"description":"No live link has that token, its owner is disabled, or feeds are turned off on this deployment — all alike.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The feed could not be built.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/subjects":{"get":{"tags":["subjects"],"summary":"List subject matters","description":"Lists the subject matters the caller can read, newest first, with cursor pagination. Requires subjects:read in scope; rows outside the caller's scope are simply absent.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of subject matters, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubjectMatterList"}}}},"400":{"description":"A filter did not resolve, or the cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["subjects"],"summary":"Create a subject matter","description":"Creates a subject matter owned by an organization, or by one department within it, optionally filed beneath broader subjects of the same organization. Requires subjects:write over the owner.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubjectMatterRequest"}}}},"responses":{"201":{"description":"The created subject matter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubjectMatter"}}}},"400":{"description":"The request body failed validation, or a parent is the subject itself.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold subjects:write over the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, the department within it, or a parent subject in it does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a subject with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/subjects/{subject_id}/permanent-deletion":{"get":{"tags":["subjects"],"summary":"Preview permanently deleting a subject matter","description":"Counts everything DELETE on this path would untag or remove: the learning records, events, quizzes, courses and certification types tagged with the subject, the proof rules scoped to it, and its narrower subjects. Nothing is changed. Requires subjects:delete — a grant separate from subjects:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"subject_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this subject but does not hold subjects:delete over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible subject matter has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["subjects"],"summary":"Permanently delete a subject matter","description":"Hard-deletes the subject — unlike DELETE /subjects/{subject_id}, which only archives. Every learning record, event, quiz, SCORM course, external course and certification type loses the tag (the rows themselves stay), so hours and record counts filed under it stop satisfying any certification rule on this subject; proof rules scoped to the subject alone are deleted, and hierarchy edges go. Irreversible. Preview the cost with GET first. Requires subjects:delete — a grant separate from subjects:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"subject_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"204":{"description":"The subject matter and its tags are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this subject but does not hold subjects:delete over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible subject matter has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/subjects/{subject_id}":{"get":{"tags":["subjects"],"summary":"Get a subject matter","description":"Returns one subject matter, by UUID id or by slug resolved within ?org=. Requires subjects:read over its owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"subject_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The subject matter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubjectMatter"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible subject matter has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["subjects"],"summary":"Update a subject matter","description":"Changes a subject matter's slug, name or description. Requires subjects:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"subject_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSubjectMatterRequest"}}}},"responses":{"200":{"description":"The updated subject matter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubjectMatter"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this subject but does not hold subjects:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible subject matter has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a subject with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["subjects"],"summary":"Archive a subject matter","description":"Soft-deletes a subject matter by setting archived_at; records filed under it keep their tags. Requires subjects:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"subject_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The archived subject matter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubjectMatter"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this subject but does not hold subjects:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible subject matter has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/subjects/{subject_id}/hierarchy":{"get":{"tags":["subjects"],"summary":"Read a subject matter's place in the hierarchy","description":"Returns the subject with the broader subjects it sits beneath (its parents, and every ancestor above them) and the narrower subjects directly beneath it. Requires subjects:read over the subject's owner; the related subjects are named regardless of their own owners.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"subject_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The subject and its parents, ancestors and children.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubjectMatterHierarchy"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible subject matter has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/subjects/{subject_id}/parents":{"put":{"tags":["subjects"],"summary":"Set a subject matter's parents","description":"Replaces the broader subjects this one sits beneath — \"arithmetic\" under \"mathematics\". Parents must belong to the subject's own organization, and the hierarchy stays acyclic: a subject cannot be placed beneath itself or beneath one of its own descendants. Requires subjects:write over the subject's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"subject_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetSubjectMatterParentsRequest"}}}},"responses":{"200":{"description":"The subject with its new parents.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubjectMatterHierarchy"}}}},"400":{"description":"The body failed validation, or a parent is the subject itself or one beneath it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this subject but does not hold subjects:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible subject matter has that id or slug, or a parent is not in its organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/submissions":{"post":{"tags":["records"],"summary":"Submit a record and its evidence in one request","description":"Creates a learning record together with its evidence rows, atomically. The record and evidence parts are JSON-encoded (shaped like CreateLearningRecordRequest and an array of CreateSupportingEvidenceRequest); the bytes of at most one file evidence entry travel in the file part, so no separate upload round trip is needed. Requires records:write over the owner — or none at all for a pending submission about yourself, which a manager then approves.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/LearningRecordSubmissionForm"}}}},"responses":{"201":{"description":"The created record and its evidence rows.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecordSubmission"}}}},"400":{"description":"A part failed validation, or the file part does not match the declared file evidence.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold records:write over the owner, and this is not a pending submission about themselves.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department, learner, or a tagged subject does not exist there.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a record with that external_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The file could not be stored, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records":{"get":{"tags":["records"],"summary":"List learning records","description":"Lists the learning records the caller can read — their records:read scope, their own records, and the pending submissions of learners in their manager chain — newest first, with cursor pagination.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only records about this learner (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":false,"description":"Only records about this learner (`users.id`).","name":"learner","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Only records filed under this subject matter: UUID id, or slug resolved within org."},"required":false,"description":"Only records filed under this subject matter: UUID id, or slug resolved within org.","name":"subject","in":"query"},{"schema":{"type":"string","enum":["pending","rejected","in_progress","completed","expired","revoked"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","enum":["score","date_range","quantity","url","file","pass_fail","note"],"description":"Only records carrying at least one evidence row of this type."},"required":false,"description":"Only records carrying at least one evidence row of this type.","name":"evidence_type","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of records, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecordList"}}}},"400":{"description":"A filter did not resolve, or the cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["records"],"summary":"Create a learning record","description":"Creates a record owned by an organization or one of its departments, optionally tagged with subjects. Requires records:write over the owner. Omit learner_user_id to record about yourself. hidden_from_learner keeps the record from the learner's own view.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLearningRecordRequest"}}}},"responses":{"201":{"description":"The created record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecord"}}}},"400":{"description":"The request body failed validation, a pending submission about yourself omitted completed_at, or a hidden record was given a submission status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold records:write over the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department, learner, or a tagged subject does not exist there.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a record with that external_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/export.csv":{"get":{"tags":["records"],"summary":"Export learning records as CSV","description":"The learning records the caller can read — exactly what `GET /records` lists, under the same filters — as one CSV file, newest first, one row per record with the columns `id`, `owner_organization_id`, `owner_organization_name`, `owner_department_id`, `owner_department_name`, `learner_user_id`, `learner_display_name`, `learner_email`, `title`, `description`, `status`, `completed_at`, `expires_at`, `external_id`, `recorded_by`, `approved_by`, `approved_at`, `rejected_by`, `rejected_at`, `rejection_note`, `hidden_from_learner`, `subject_ids`, `subject_names`, `evidence_count`, `hours`, `archived_at`, `created_at`. Subjects are `; `-joined lists, timestamps ISO 8601, and the file opens with a UTF-8 byte order mark for spreadsheets. The export carries at most `limit` rows — up to 5000 for most callers, 50000 for superusers — and is refused outright, never truncated, when more records match: narrow the filters or raise the limit.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only records about this learner (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":false,"description":"Only records about this learner (`users.id`).","name":"learner","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Only records filed under this subject matter: UUID id, or slug resolved within org."},"required":false,"description":"Only records filed under this subject matter: UUID id, or slug resolved within org.","name":"subject","in":"query"},{"schema":{"type":"string","enum":["pending","rejected","in_progress","completed","expired","revoked"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"string","enum":["score","date_range","quantity","url","file","pass_fail","note"],"description":"Only records carrying at least one evidence row of this type."},"required":false,"description":"Only records carrying at least one evidence row of this type.","name":"evidence_type","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":50000,"description":"The most rows to export: 1-5000 (superusers: up to 50000). Defaults to the caller's ceiling. When more records match than this, the export is refused rather than truncated.","example":1000},"required":false,"description":"The most rows to export: 1-5000 (superusers: up to 50000). Defaults to the caller's ceiling. When more records match than this, the export is refused rather than truncated.","name":"limit","in":"query"}],"responses":{"200":{"description":"The CSV file, served as an attachment named `learning-records-<date>.csv`.","content":{"text/csv":{"schema":{"type":"string"}}}},"400":{"description":"A filter did not resolve, or the limit is above the caller's ceiling.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"More records match than the limit allows; nothing is exported.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/{record_id}/permanent-deletion":{"get":{"tags":["records"],"summary":"Preview permanently deleting a learning record","description":"Counts everything DELETE on this path would remove or unlink: the supporting evidence and its files, the subject tags whose hours stop counting, the imported LMS grade behind the record, and the quiz, SCORM and attendance rows that reported into it. Nothing is changed. Requires records:delete over the record's owner — a grant separate from records:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold records:delete over the record's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["records"],"summary":"Permanently delete a learning record","description":"Hard-deletes the record — unlike DELETE /records/{record_id}, which only archives it and can be undone. Its supporting evidence goes with it, uploaded files included, and so do its subject tags: the hours and completion it credited toward certification requirements disappear. Quiz, SCORM and event attendance rows that reported into it survive, unlinked. Irreversible. Preview the cost with GET first. Requires records:delete over the record's owner — a grant separate from records:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"responses":{"204":{"description":"The record and its evidence are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold records:delete over the record's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/{record_id}":{"get":{"tags":["records"],"summary":"Get a learning record","description":"Returns one record with its subjects. Requires records:read over its owner, being its learner, or — while it is pending — being in its learner's manager chain.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"responses":{"200":{"description":"The record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecord"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["records"],"summary":"Archive a learning record","description":"Soft-deletes a record by setting archived_at; its evidence and blobs are kept, so it can be un-archived. Requires records:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"responses":{"200":{"description":"The archived record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecord"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this record but does not hold records:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["records"],"summary":"Update a learning record","description":"Changes a record's fields, and replaces its subject set when subject_matter_ids is present. Requires records:write; hidden_from_learner in particular is only ever set by a records:write holder, and never on a pending or rejected record.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateLearningRecordRequest"}}}},"responses":{"200":{"description":"The updated record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecord"}}}},"400":{"description":"The request body failed validation, or would leave a hidden record in a submission status.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this record but does not hold records:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a record with that external_id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/{record_id}/approve":{"post":{"tags":["records"],"summary":"Approve a pending learning record","description":"Moves a learner's pending submission into the real history — completed by default, or in_progress — and stamps who approved it and when. Requires records:write over the record's owner, or a role in the learner's manager chain; a learner cannot approve their own submission.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApproveLearningRecordRequest"}}}},"responses":{"200":{"description":"The approved record.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecord"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller neither holds records:write over the record's owner nor is in the learner's manager chain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The record is not pending — already approved, or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/{record_id}/reject":{"post":{"tags":["records"],"summary":"Reject a pending learning record","description":"Sends a learner's pending submission back with a note saying what to fix, and stamps who rejected it and when. The learner may edit the record and resubmit it to pending, which clears the rejection. Requires records:write over the record's owner, or a role in the learner's manager chain; a learner cannot reject their own submission.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RejectLearningRecordRequest"}}}},"responses":{"200":{"description":"The rejected record, carrying the note for its learner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LearningRecord"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller neither holds records:write over the record's owner nor is in the learner's manager chain.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The record is not pending — already decided, or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/{record_id}/evidence":{"get":{"tags":["records"],"summary":"List a record's evidence","description":"Lists every evidence row of a record, newest first. Requires records:read over the record, being its learner, or — while it is pending — being in its learner's manager chain.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"responses":{"200":{"description":"The record's evidence, newest first.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SupportingEvidence"}}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["records"],"summary":"Add evidence to a record","description":"Attaches one measurement to a record. For file evidence, upload the file first via the uploads endpoint and pass the resulting pathname here. Requires records:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSupportingEvidenceRequest"}}}},"responses":{"201":{"description":"The created evidence row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupportingEvidence"}}}},"400":{"description":"The payload failed validation, or the uploaded blob is missing or does not match it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this record but does not hold records:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That upload has already been attached to an evidence row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/records/{record_id}/evidence/uploads":{"post":{"tags":["records"],"summary":"Request an upload URL for file evidence","description":"Issues a presigned URL to PUT one file straight to private blob storage, scoped to this record, the declared content type and a 25 MB ceiling. Requires records:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"record_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUploadTicketRequest"}}}},"responses":{"201":{"description":"Where to PUT the file, and the evidence id minted for it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this record but does not hold records:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible record has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/evidence/{evidence_id}":{"patch":{"tags":["records"],"summary":"Update an evidence row","description":"Changes an evidence row's label or replaces its payload (same evidence_type; not for files). Requires records:write over the owning record.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"evidence_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSupportingEvidenceRequest"}}}},"responses":{"200":{"description":"The updated evidence row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SupportingEvidence"}}}},"400":{"description":"The payload failed validation, changed type, or replaced a file payload.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read the record but does not hold records:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible evidence row has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["records"],"summary":"Delete an evidence row","description":"Removes an evidence row for good — a mistaken measurement is deleted, not archived — and deletes its blob, if it has one. Requires records:write over the owning record.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"evidence_id","in":"path"}],"responses":{"204":{"description":"The evidence row (and its blob, if any) is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read the record but does not hold records:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible evidence row has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/evidence/{evidence_id}/download":{"get":{"tags":["records"],"summary":"Get a download URL for file evidence","description":"Issues a short-lived presigned GET for the evidence row's private blob, after re-checking read permission. Requires records:read over the owning record, being its learner, or — while the record is pending — being in its learner's manager chain.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"evidence_id","in":"path"}],"responses":{"200":{"description":"Where to fetch the file from, for the next five minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownloadTicket"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible evidence row has that id, or it has no file attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The download URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/status":{"get":{"tags":["certification-status"],"summary":"Evaluate certification status","description":"Evaluates every certification requirement of a user: the types their unexpired role grants require, the current award, the due date, and per-rule progress since the current award. Any bearer user may read their own status; reading another user's requires certifications:read, and returns only types in the caller's scope.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Evaluate this user (`users.id`) instead of the caller. Requires certifications:read; the result is filtered to types in the caller's scope."},"required":false,"description":"Evaluate this user (`users.id`) instead of the caller. Requires certifications:read; the result is filtered to types in the caller's scope.","name":"user","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The user's evaluated certification requirements.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCertificationStatusList"}}}},"400":{"description":"The org filter did not resolve.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Another user was asked for without certifications:read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/claim":{"post":{"tags":["certification-status"],"summary":"Claim an automatic certification","description":"Awards the caller a certification of an automatic-mode type they are required to hold, after the server re-validates that every proof rule is satisfied. Answers 409 when a rule is not met, or when the current award changed under the claim (retry after re-reading status).","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification type."},"required":true,"description":"UUID id of the certification type.","name":"cert_id","in":"path"}],"responses":{"201":{"description":"The new award.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationAward"}}}},"400":{"description":"The type is awarded by approval — file a request instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"No unexpired role grant of the caller requires this type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"That certification type does not exist, or is archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A proof rule is not satisfied, or the current award changed underneath.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/request":{"post":{"tags":["certification-status"],"summary":"Request a certification","description":"Files a request for an approval-mode type the caller is required to hold, to be decided by a holder of certifications:write. One pending request per user and type. An automatic type accepts a request too when it carries a manual_sign_off rule: the request asks a certifier for the sign-off (recorded from the approvals queue), after which the certifier approves the request or the caller claims the type themselves.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification type."},"required":true,"description":"UUID id of the certification type.","name":"cert_id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestCertificationRequest"}}}},"responses":{"201":{"description":"The filed request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRequest"}}}},"400":{"description":"The type is automatic with no sign-off rule — claim it instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"No unexpired role grant of the caller requires this type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"That certification type does not exist, or is archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A request is already pending, or the caller is not in the organization's directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-requests/{request_id}/withdraw":{"post":{"tags":["certification-status"],"summary":"Withdraw an own request","description":"Withdraws the caller's own still-pending request.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification request."},"required":true,"description":"UUID id of the certification request.","name":"request_id","in":"path"}],"responses":{"200":{"description":"The withdrawn request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRequest"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No request of the caller has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The request has already been decided.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-requests/{request_id}/approve":{"post":{"tags":["certification-status"],"summary":"Approve a request","description":"Approves a pending request: inserts the award and stamps the request in one transaction. The deciding human is the authority — rules are not re-checked. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification request."},"required":true,"description":"UUID id of the certification request.","name":"request_id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DecideCertificationRequestRequest"}}}},"responses":{"200":{"description":"The approved request, with the resulting award's id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRequest"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this request but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible request has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The request has already been decided or withdrawn.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-requests/{request_id}/reject":{"post":{"tags":["certification-status"],"summary":"Reject a request","description":"Rejects a pending request. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification request."},"required":true,"description":"UUID id of the certification request.","name":"request_id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DecideCertificationRequestRequest"}}}},"responses":{"200":{"description":"The rejected request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRequest"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this request but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible request has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The request has already been decided or withdrawn.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-requests":{"get":{"tags":["certification-status"],"summary":"List certification requests","description":"The approvals queue: requests whose certification type the caller holds certifications:write over, newest first, with cursor pagination. Defaults to pending requests only.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","enum":["pending","approved","rejected","withdrawn"],"description":"Filter requests by status. Defaults to pending — the open queue."},"required":false,"description":"Filter requests by status. Defaults to pending — the open queue.","name":"status","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of requests, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRequestList"}}}},"400":{"description":"The org filter did not resolve, or the cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/grants":{"post":{"tags":["certification-status"],"summary":"Grant a certification directly","description":"Awards a certification to a user without a request — for paper history, external audits, or admin discretion. `awarded_at` may be backdated; the recert clock counts from it. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification type."},"required":true,"description":"UUID id of the certification type.","name":"cert_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GrantCertificationRequest"}}}},"responses":{"201":{"description":"The new award.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationAward"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/awards":{"get":{"tags":["certification-status"],"summary":"List a type's awards","description":"Lists the award events of one certification type, newest first, revoked ones included. Requires certifications:read over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification type."},"required":true,"description":"UUID id of the certification type.","name":"cert_id","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"The most recent awards of the type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationAwardList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-awards/{award_id}/revoke":{"post":{"tags":["certification-status"],"summary":"Revoke an award","description":"Revokes one award. The row stays as history; the user's status reverts to outstanding (or to their previous unrevoked award). Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the award."},"required":true,"description":"UUID id of the award.","name":"award_id","in":"path"}],"responses":{"200":{"description":"The revoked award.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationAward"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this award but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible award has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The award is already revoked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/due-dates":{"get":{"tags":["certification-status"],"summary":"List a type's initial due dates","description":"Lists the admin-set per-person initial due dates of one certification type, soonest first. Requires certifications:read over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification type."},"required":true,"description":"UUID id of the certification type.","name":"cert_id","in":"path"}],"responses":{"200":{"description":"The type's due dates.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationDueDateList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/due-dates/{user_id}":{"put":{"tags":["certification-status"],"summary":"Set a member's initial due date","description":"Sets (or replaces) when one organization member's initial certification falls due, overriding any role-level initial due date. Requires certifications:write over the type's owner. The member must be in the organization's directory.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification type."},"required":true,"description":"UUID id of the certification type.","name":"cert_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"`users.id` of the organization member."},"required":true,"description":"`users.id` of the organization member.","name":"user_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetCertificationDueDateRequest"}}}},"responses":{"200":{"description":"The stored due date.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationDueDate"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id, or the user is not in the organization's directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["certification-status"],"summary":"Clear a member's initial due date","description":"Clears a member's per-person initial due date; their requirement falls back to the role-level initial due date, or shows \"No due date set\" when no role sets one. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the certification type."},"required":true,"description":"UUID id of the certification type.","name":"cert_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"`users.id` of the organization member."},"required":true,"description":"`users.id` of the organization member.","name":"user_id","in":"path"}],"responses":{"200":{"description":"The removed due date.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationDueDate"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id, or no due date was set.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-rules/{rule_id}/sign-offs":{"post":{"tags":["certification-status"],"summary":"Record a sign-off","description":"Records that a user satisfies one manual_sign_off rule. `signed_off_at` may be backdated; a renewal needs a sign-off after the current award. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the proof rule."},"required":true,"description":"UUID id of the proof rule.","name":"rule_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCertificationSignOffRequest"}}}},"responses":{"201":{"description":"The recorded sign-off.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationSignOff"}}}},"400":{"description":"The rule is not a manual_sign_off rule, or the body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this rule but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible rule has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["certification-status"],"summary":"List a rule's sign-offs","description":"Lists the sign-offs recorded against one rule, newest first, optionally for one user. Requires certifications:read over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the proof rule."},"required":true,"description":"UUID id of the proof rule.","name":"rule_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"Evaluate this user (`users.id`) instead of the caller. Requires certifications:read; the result is filtered to types in the caller's scope."},"required":false,"description":"Evaluate this user (`users.id`) instead of the caller. Requires certifications:read; the result is filtered to types in the caller's scope.","name":"user","in":"query"}],"responses":{"200":{"description":"The rule's sign-offs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationSignOffList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible rule has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-sign-offs/{sign_off_id}":{"delete":{"tags":["certification-status"],"summary":"Delete a sign-off","description":"Hard-deletes a mistaken sign-off, the way evidence is deleted. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the sign-off."},"required":true,"description":"UUID id of the sign-off.","name":"sign_off_id","in":"path"}],"responses":{"200":{"description":"The deleted sign-off.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationSignOff"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this sign-off but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible sign-off has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications":{"get":{"tags":["certifications"],"summary":"List certification types","description":"Lists the certification types the caller can read, newest first, with cursor pagination. Requires certifications:read in scope; rows outside the caller's scope are simply absent.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of certification types, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationTypeList"}}}},"400":{"description":"A filter did not resolve, or the cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["certifications"],"summary":"Create a certification type","description":"Creates a certification type owned by an organization, or by one department within it, tagged with any number of subject matters of the same organization. Requires certifications:write over the owner.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCertificationTypeRequest"}}}},"responses":{"201":{"description":"The created certification type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationType"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold certifications:write over the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, the department within it, or a subject matter does not exist (or a subject belongs to another organization).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a certification type with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/attachments/{attachment_id}/download":{"get":{"tags":["certifications"],"summary":"Get a download URL for a certification type attachment","description":"Issues a short-lived presigned GET for the private attachment. Readable by anyone an unexpired role grant requires the type of, and by holders of certifications:read over its owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"Where to fetch the file from, for the next five minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownloadTicket"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The download URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/attachment-uploads":{"post":{"tags":["certifications"],"summary":"Request an upload URL for a certification type attachment","description":"Issues a presigned URL to PUT one file of any type straight to private blob storage, scoped to this certification type, the declared content type and a 500 MB ceiling. Attach it with POST /certifications/{cert_id}/attachments and the returned pathname afterwards. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDescriptionUploadRequest"}}}},"responses":{"201":{"description":"Where to PUT the file, and the pathname the attachment will point at.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionUploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this certification type but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/attachments":{"post":{"tags":["certifications"],"summary":"Attach an uploaded file to a certification type","description":"Creates the attachment at the end of the authored order, pointing at a pathname an upload ticket was issued for (after PUTting the file there). The content type is read back from the store, never from the request. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDescriptionAttachmentRequest"}}}},"responses":{"201":{"description":"The created attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionAttachment"}}}},"400":{"description":"The body failed validation, or the pathname was not uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this certification type but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That upload is already attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/attachments/{attachment_id}":{"patch":{"tags":["certifications"],"summary":"Relabel or reorder a certification type attachment","description":"Changes the attachment's label (null or empty clears it back to the file name), whether it shows on the page or downloads only, or its slot in the authored order. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDescriptionAttachmentRequest"}}}},"responses":{"200":{"description":"The updated attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionAttachment"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this certification type but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["certifications"],"summary":"Delete a certification type attachment","description":"Removes the attachment; the blob it pointed at is deleted best-effort. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"204":{"description":"The attachment is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this certification type but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/subjects":{"put":{"tags":["certifications"],"summary":"Replace a certification type's subject tags","description":"Sets the complete set of subject matters whose records and evidence satisfy the type's record-based rules — a record filed under any of them counts. Awards already given stand; only evaluation from now on follows the new set. Subjects of the type's own organization only. A tag one of the type's rules is narrowed to cannot be removed until that rule is deleted or widened. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetCertificationTypeSubjectsRequest"}}}},"responses":{"200":{"description":"The type's subjects after the change, alphabetically.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CertificationTypeSubject"}}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug, or a subject is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A rule of the type is narrowed to a tag the new set drops.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/permanent-deletion":{"get":{"tags":["certifications"],"summary":"Preview permanently deleting a certification type","description":"Counts everything DELETE on this path would remove: awards (people lose the certification, past ones included), pending requests, sign-offs, due dates, proof rules, role requirement links, pathway level memberships and attachments. Nothing is changed. Requires certifications:delete — a grant separate from certifications:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["certifications"],"summary":"Permanently delete a certification type","description":"Hard-deletes the type and everything that depends on it — unlike DELETE /certifications/{cert_id}, which only archives. Every award of the type is deleted, so current holders lose the certification and past certifications vanish from every history; pending requests, sign-offs, due dates, rules, role requirement links, pathway level memberships and attachments go with it. Irreversible. Preview the cost with GET first. Requires certifications:delete — a grant separate from certifications:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"204":{"description":"The certification type and its dependent rows are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/duplicate":{"post":{"tags":["certifications"],"summary":"Duplicate a certification type","description":"Creates a copy of a certification type: its descriptions, awarding mode, subject tags and every proof rule, and its description attachments — each file copied in the store, so deleting either type later leaves the other's files alone. Role requirements, awards, requests, due dates and sign-offs stay with the original. The copy is named after the original with \" (copy)\" appended and takes the first free -copy slug unless name and slug are given. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateCertificationTypeRequest"}}}},"responses":{"201":{"description":"The copy, with its rules and attachments.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationTypeDetail"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a certification type with the requested slug, or another copy took the generated one first — retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write or a file copy failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"The type has attachments and file storage is not configured here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}":{"get":{"tags":["certifications"],"summary":"Get a certification type","description":"Returns one certification type with its proof rules, by UUID id or by slug resolved within ?org=. Requires certifications:read over its owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The certification type and its rules.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationTypeDetail"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["certifications"],"summary":"Update a certification type","description":"Changes a certification type's slug, name, description or awarding mode. The subject tags are replaced as a set through PUT /certifications/{cert_id}/subjects. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCertificationTypeRequest"}}}},"responses":{"200":{"description":"The updated certification type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationType"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a certification type with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["certifications"],"summary":"Archive a certification type","description":"Soft-deletes a certification type by setting archived_at. Archived types stop being evaluated as requirements; awards already given stay readable. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The archived certification type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationType"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certifications/{cert_id}/rules":{"post":{"tags":["certifications"],"summary":"Add a proof rule","description":"Adds one proof rule to a certification type; all rules of a type must be satisfied at once for it to be awardable. Note that date_range evidence counts its full elapsed wall-clock time towards subject_hours rules, so size thresholds accordingly. A subject_hours or subject_record_count rule counts records on every subject tag of the type unless subject_matter_id narrows it to one of them. A scorm_course_complete rule is satisfied by an attempt of the hosted course reported complete without an explicit failed verdict; a job_aid_viewed rule by an open of the job aid's page. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"forklift-operator"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"cert_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCertificationRuleRequest"}}}},"responses":{"201":{"description":"The created rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRule"}}}},"400":{"description":"The request body failed validation, or a reference (quiz, course, subject tag) does not belong to the type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this type but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible certification type has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/certification-rules/{rule_id}":{"patch":{"tags":["certifications"],"summary":"Update a proof rule","description":"Changes a rule's label or configuration. The rule kind is immutable — delete and recreate a rule that should mean something else. Requires certifications:write over the type's owner. Progress is recomputed retroactively; award history is unaffected.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the proof rule."},"required":true,"description":"UUID id of the proof rule.","name":"rule_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCertificationRuleRequest"}}}},"responses":{"200":{"description":"The updated rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRule"}}}},"400":{"description":"The request body failed validation, or does not fit the rule's kind.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this rule but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible rule has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["certifications"],"summary":"Delete a proof rule","description":"Hard-deletes a rule. Every sign-off recorded against it cascades away with it. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the proof rule."},"required":true,"description":"UUID id of the proof rule.","name":"rule_id","in":"path"}],"responses":{"200":{"description":"The deleted rule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationRule"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this rule but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible rule has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{org_id}/departments/{dept_id}/roles/{role_id}/required-certifications":{"get":{"tags":["certifications"],"summary":"List a role's required certifications","description":"Lists the certification types required of everyone holding one department role, with each link's recertification scheme. Requires certifications:read; links whose type is outside the caller's scope are absent.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within the organization.","example":"quality-assurance"},"required":true,"description":"Department UUID id or slug, resolved within the organization.","name":"dept_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Role UUID id or slug, resolved within the department.","example":"forklift-driver"},"required":true,"description":"Role UUID id or slug, resolved within the department.","name":"role_id","in":"path"}],"responses":{"200":{"description":"The role's requirement links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleRequiredCertificationList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department or role does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["certifications"],"summary":"Require a certification of a role","description":"Links a certification type to a department role, with a recertification scheme and an optional role-level initial due date every holder inherits. Requires certifications:write over the type's owner. The type must belong to the role's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug.","example":"acme-corporation"},"required":true,"description":"Organization UUID id or slug.","name":"org_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within the organization.","example":"quality-assurance"},"required":true,"description":"Department UUID id or slug, resolved within the organization.","name":"dept_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Role UUID id or slug, resolved within the department.","example":"forklift-driver"},"required":true,"description":"Role UUID id or slug, resolved within the department.","name":"role_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRoleRequiredCertificationRequest"}}}},"responses":{"201":{"description":"The created requirement link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleRequiredCertification"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold certifications:write over the type's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department, role or certification type does not exist (or the type belongs to another organization).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The role already requires that certification type.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/role-required-certifications/{link_id}":{"patch":{"tags":["certifications"],"summary":"Change a requirement's schedule","description":"Replaces the recertification scheme and role-level initial due date of one role⇄type link; the scheme, its configuration and the date always travel together — an omitted initial_due_at clears the role-level deadline. Requires certifications:write over the type's owner. Members' due dates are recomputed at read time.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the role⇄certification requirement link."},"required":true,"description":"UUID id of the role⇄certification requirement link.","name":"link_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRoleRequiredCertificationRequest"}}}},"responses":{"200":{"description":"The updated requirement link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleRequiredCertification"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this link but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible requirement link has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["certifications"],"summary":"Stop requiring a certification of a role","description":"Hard-deletes one role⇄type link, like revoking a role grant: the requirement simply stops appearing. Awards and history are untouched. Requires certifications:write over the type's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the role⇄certification requirement link."},"required":true,"description":"UUID id of the role⇄certification requirement link.","name":"link_id","in":"path"}],"responses":{"200":{"description":"The deleted requirement link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleRequiredCertification"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this link but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible requirement link has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/organizations/{organizationId}/departments/{departmentId}/roles/{roleId}/certification-completion":{"get":{"tags":["team-completion"],"summary":"A role's trainee certification completion","description":"Tallies every current holder of the role against its required certification types: who holds a current award and is not past due, per type and overall, with the named roster. Requires the caller to hold a role in this role's manager chain, or be a superuser.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","example":"6f3b34d8-3c5e-4dd9-9f4e-2b52f0d9a1c7"},"required":true,"name":"organizationId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"0b8f9a3e-97a4-4f2f-b3a3-51f2f8f0f7f4"},"required":true,"name":"departmentId","in":"path"},{"schema":{"type":"string","format":"uuid","example":"9c2f1f7e-4a35-4b8f-8d21-3f5b2a7c9d10"},"required":true,"name":"roleId","in":"path"}],"responses":{"200":{"description":"The role's aggregate completion, tallies and roster alike.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RoleCertificationCompletion"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is neither in the role's manager chain nor a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, department, or role does not exist there.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/team/certification-completion":{"get":{"tags":["team-completion"],"summary":"The caller's team certification completion","description":"The aggregate completion of every role reachable walking the reporting lines down from the caller's unexpired roles, limited to roles that require at least one live certification type. Empty for a caller who manages nothing.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"One aggregate per managed role that requires certifications.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamCertificationCompletionList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-banks/{bank_id}/questions/order":{"put":{"tags":["quizzes"],"summary":"Reorder a bank's questions","description":"Sets the authored order of the bank's questions — the order fixed quizzes present them in — from a list of every question id, archived ones included, first to last. The list must name the bank's questions exactly, so a bank that changed since it was listed answers 400 and nothing moves; its 1000-id ceiling is the bank's own size cap, so every bank fits. Papers already started keep the order they were dealt in. Requires quizzes:write over the bank's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReorderBankQuestionsRequest"}}}},"responses":{"200":{"description":"The bank's questions in their new order, archived ones included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionList"}}}},"400":{"description":"The request body failed validation, or question_ids is not exactly the bank's questions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this bank but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-banks/{bank_id}/questions":{"get":{"tags":["quizzes"],"summary":"List a bank's questions","description":"The bank's questions in authored order, correct answers included — this is the authoring read, so it requires quizzes:read over the bank's owner. Takers never see this shape; their attempts serve questions with the answers stripped.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"}],"responses":{"200":{"description":"The bank's questions, oldest position first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["quizzes"],"summary":"Add a question","description":"Adds one question to a bank — after its live questions, ahead of any archived ones — configured per kind: true_false with its correct_boolean; numeric with correct_number and tolerance; the rest with up to 10 options — multiple_choice (exactly one correct), multi_select (at least one, scored all-or-nothing), ordering (items in their correct order), short_answer (the accepted spellings, one or more) and matching (label/match_label pairs); fill_in_blank marks its blanks in the prompt with runs of three or more underscores and lists the accepted answers of each blank as options tagged with blank_index; long_answer takes no answer at all and is always graded by hand, as is drawing, whose taker draws on a canvas — over drawing_starting_image, a data URL, when one is attached. Every question carries points (default 1), may require manual grading — a marker then awards its points from the grading queue — and may carry a markdown rubric for that marker, shown to takers only when rubric_visible_to_takers. The prompt is plaintext by default; prompt_format markdown renders it as Markdown, and pdf attaches a previously uploaded PDF (see the question-pdfs endpoint). A bank holds at most 1000 questions, archived ones included — the most its reorder write can name — and answers 409 once full. Requires quizzes:write over the bank's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuizQuestionRequest"}}}},"responses":{"201":{"description":"The created question, options included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"400":{"description":"The request body failed validation, does not fit the question kind, or names a PDF upload that is missing or not this bank's.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this bank but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The bank is full, or that PDF upload is already attached to another question.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-banks/{bank_id}/question-pdfs":{"post":{"tags":["quizzes"],"summary":"Request an upload URL for a question PDF","description":"Issues a presigned URL to PUT one PDF straight to private blob storage, scoped to this bank, application/pdf and a 25 MB ceiling. Create (or update) the question with the returned pathname as prompt_pdf.pathname afterwards. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuestionPdfUploadRequest"}}}},"responses":{"201":{"description":"Where to PUT the file, and the pathname the question will point at.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionPdfUploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this bank but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-questions/{question_id}/pdf":{"get":{"tags":["quizzes"],"summary":"Get a download URL for a question's PDF","description":"Issues a short-lived presigned GET for a pdf question's private blob — the authoring read. Requires quizzes:read over the bank's owner; takers fetch it through their attempt's question pdf endpoint instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the question."},"required":true,"description":"UUID id of the question.","name":"question_id","in":"path"}],"responses":{"200":{"description":"Where to fetch the PDF from, for the next five minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownloadTicket"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question has that id, or it has no PDF attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The download URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-banks/{bank_id}/permanent-deletion":{"get":{"tags":["quizzes"],"summary":"Preview permanently deleting a question bank","description":"Counts everything DELETE on this path would remove or alter: attempts still in progress on its questions, answers to them in finished attempts, the quizzes drawing from the bank (and how many draw from nothing else), and its questions with their PDF prompts. Nothing is changed. Requires quizzes:delete over the bank's owner — a grant separate from quizzes:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this bank but does not hold quizzes:delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["quizzes"],"summary":"Permanently delete a question bank","description":"Hard-deletes the bank — unlike archiving, which only stops it feeding new attempts. Its questions, options and PDF prompts go, and every quiz stops drawing from it. Finished attempts keep their scores, pass/fail results and learning records but lose their answers to its questions; attempts still in progress on them are discarded so their takers start afresh (expired ones are auto-submitted first). Irreversible. Preview the cost with GET first. Requires quizzes:delete over the bank's owner — a grant separate from quizzes:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"204":{"description":"The bank, its questions and their answers are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this bank but does not hold quizzes:delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-banks":{"get":{"tags":["quizzes"],"summary":"List question banks","description":"Lists the question banks the caller can read, newest first, with cursor pagination. Requires quizzes:read in scope; rows outside the caller's scope are simply absent.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of question banks, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionBankList"}}}},"400":{"description":"A filter did not resolve, or the cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["quizzes"],"summary":"Create a question bank","description":"Creates a reusable question bank owned by an organization, or by one department within it. Banks are a shared library: any quiz of the organization may draw from them. Requires quizzes:write over the owner.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuizQuestionBankRequest"}}}},"responses":{"201":{"description":"The created question bank.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionBank"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold quizzes:write over the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, or the department within it, does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a question bank with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-banks/{bank_id}":{"get":{"tags":["quizzes"],"summary":"Read a question bank","description":"One question bank. Requires quizzes:read over the owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The question bank.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionBank"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["quizzes"],"summary":"Update a question bank","description":"Changes a bank's slug, name or description, or archives or restores it with `archived`. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQuizQuestionBankRequest"}}}},"responses":{"200":{"description":"The updated question bank.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionBank"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this bank but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a question bank with the new slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["quizzes"],"summary":"Archive a question bank","description":"Soft-deletes a question bank by setting archived_at. Its questions stop being drawn into new attempts through any quiz link; history is untouched. PATCH with `archived: false` restores it; permanent deletion is DELETE on /quiz-banks/{bank_id}/permanent-deletion. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"fire-safety-basics"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"bank_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The archived question bank.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestionBank"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this bank but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question bank has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-questions/{question_id}/copy":{"post":{"tags":["quizzes"],"summary":"Copy a question into a bank","description":"Creates a copy of the question — prompt, answers, points, rubric and explanation — in the target bank, which may be the question's own, as a live question after the bank's live ones. A prompt PDF is copied in the store, so each question keeps a file of its own. The original is untouched. Requires quizzes:read over the question's bank and quizzes:write over the target, both in the same organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the question."},"required":true,"description":"UUID id of the question.","name":"question_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionTargetBankRequest"}}}},"responses":{"201":{"description":"The copy, options included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"400":{"description":"The request body failed validation, the target bank is in another organization, or (for a move) the question is already in it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller may see the question or the target bank but not write it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question has that id, or no visible bank has the target id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The target bank is full.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write or the PDF copy failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"The question has a prompt PDF and file storage is not configured here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-questions/{question_id}/move":{"post":{"tags":["quizzes"],"summary":"Move a question to another bank","description":"Moves the question into the target bank — how a full bank is relieved into a new one. It keeps its id, so attempts that presented it still review and grade against it; quizzes drawing on the old bank stop presenting it, those drawing on the new one start. A live question lands after the target's live questions, an archived one at the end; a prompt PDF moves into the target bank's files. Requires quizzes:write over both banks, which must share an organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the question."},"required":true,"description":"UUID id of the question.","name":"question_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuestionTargetBankRequest"}}}},"responses":{"200":{"description":"The moved question, options included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"400":{"description":"The request body failed validation, the target bank is in another organization, or (for a move) the question is already in it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller may see the question or the target bank but not write it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question has that id, or no visible bank has the target id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The target bank is full.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write or the PDF copy failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"The question has a prompt PDF and file storage is not configured here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-questions/{question_id}":{"patch":{"tags":["quizzes"],"summary":"Update a question","description":"Changes a question's prompt, its format, explanation or answers; sending options replaces the whole option set, and sending prompt_pdf attaches or replaces the PDF (switching prompt_format away from pdf discards it). prompt_pdf_prefer_inline_display switches that PDF between drawing beside the question and downloading only, without re-sending the file. drawing_starting_image replaces (null: clears) a drawing question's canvas image. Points, the manual-grading flag and the rubric may change too; points and the flag reach papers built from now on only, since every attempt snapshots them at start. The kind is immutable. Submitted attempts keep the grades they were given — history is never re-graded — but their answer review renders the questions as they are now. Requires quizzes:write over the bank's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the question."},"required":true,"description":"UUID id of the question.","name":"question_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQuizQuestionRequest"}}}},"responses":{"200":{"description":"The updated question, options included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"400":{"description":"The request body failed validation, does not fit the question's kind or format, or names a PDF upload that is missing or not this bank's.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this question but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That PDF upload is already attached to another question.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["quizzes"],"summary":"Delete a question","description":"Hard-deletes a question nobody has attempted, along with its attached PDF if any. A question presented in any attempt is kept for history and answers 409 — archive it instead. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the question."},"required":true,"description":"UUID id of the question.","name":"question_id","in":"path"}],"responses":{"200":{"description":"The deleted question, as it was (options omitted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this question but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The question has attempt history; archive it instead.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-questions/{question_id}/archive":{"post":{"tags":["quizzes"],"summary":"Archive a question","description":"Sets archived_at: the question stops being drawn into new papers but stays readable in past attempts. It also moves to the end of the bank's order, so the live questions stay a contiguous run at the front; archiving again changes nothing. Requires quizzes:write over the bank's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the question."},"required":true,"description":"UUID id of the question.","name":"question_id","in":"path"}],"responses":{"200":{"description":"The archived question, options included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizQuestion"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this question but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible question has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/assigned":{"get":{"tags":["quiz-taking"],"summary":"List my assigned quizzes","description":"Every published quiz assigned to the caller — individually, or through any unexpired grant of an assigned role — with where they stand on each: the current and next scheduled sittings they may sit (a roster sitting counts only for those on its roster), attempts used, the open attempt to resume, and the latest result once results are visible. Needs no permission grant: being assigned is the authorization.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's assigned quizzes, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssignedQuizList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/attempts":{"post":{"tags":["quiz-taking"],"summary":"Start (or resume) an attempt","description":"Starts an attempt of an assigned, published quiz that is takeable right now — inside an open sitting for live quizzes (one open to every assignee, or a roster sitting the caller is on), any time for async ones. The paper is drawn per the quiz's selection mode, persisted, and served with the answers stripped. An attempt already in progress is returned instead of starting another (200, not 201). Refused outside a sitting, or once max_attempts is used up — counted within the current sitting for live quizzes, per quiz for async. Needs no permission grant: being assigned is the authorization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The attempt already in progress, resumed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptDetail"}}}},"201":{"description":"The freshly started attempt and its paper.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptDetail"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No quiz assigned to the caller has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"No sitting is open, no attempts remain, or the paper is empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["quizzes"],"summary":"List a quiz's attempts","description":"Every attempt of the quiz, newest first, scores included — the results view for holders of quizzes:read over the quiz's owner. Expired in-progress attempts are finalized (auto-submitted as-is) before listing, so no background job is needed.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of attempts, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptList"}}}},"400":{"description":"The cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/questions/{question_id}/pdf":{"get":{"tags":["quiz-taking"],"summary":"Get a download URL for a presented question's PDF","description":"Issues a short-lived presigned GET for the PDF of a pdf question this attempt presented — while sitting the attempt and in answer review alike. Readable by the taker themselves, and by holders of quizzes:read over the quiz. Carries no answers.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the question."},"required":true,"description":"UUID id of the question.","name":"question_id","in":"path"}],"responses":{"200":{"description":"Where to fetch the PDF from, for the next five minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownloadTicket"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id, the question is not on its paper, or it has no PDF.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The download URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/review.csv":{"get":{"tags":["quiz-taking"],"summary":"Download an attempt's answers as CSV","description":"The attempt's graded paper as one CSV file, one row per presented question in presentation order, with the columns `attempt_id`, `quiz_id`, `quiz_name`, `user_id`, `user_display_name`, `attempt_number`, `status`, `started_at`, `submitted_at`, `auto_submitted`, `score_percentage`, `passed`, `position`, `question_id`, `kind`, `prompt`, `response`, `correct_answer`, `is_correct`, `points_awarded`, `points_possible`, `feedback`, `answered_at`. Lists in a cell are `; `-separated, alternative accepted spellings `|`-separated, matched pairs read `left → right`, and the file opens with a UTF-8 byte order mark for spreadsheets. Takers may download their own scored attempt once its results are visible and the quiz shows correct answers; holders of quizzes:read or quizzes:grade over the quiz may download any attempt, in progress or not.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"responses":{"200":{"description":"The CSV file, served as an attachment.","content":{"text/csv":{"schema":{"type":"string"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Review is not (or not yet) available to the taker for this quiz.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/review.json":{"get":{"tags":["quiz-taking"],"summary":"Download an attempt's answers as JSON","description":"The attempt's graded paper — exactly the review endpoint's document — served as a JSON attachment. Takers may download their own scored attempt once its results are visible and the quiz shows correct answers; holders of quizzes:read or quizzes:grade over the quiz may download any attempt, in progress or not.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"responses":{"200":{"description":"The JSON file, served as an attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptReview"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Review is not (or not yet) available to the taker for this quiz.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/review":{"get":{"tags":["quiz-taking"],"summary":"Review an attempt's answers","description":"The attempt's paper with correct answers, the taker's responses and per-question grades — points awarded and possible, the marker's feedback, and the rubric where the reader may see it. Takers see it only once the attempt is scored, its results are visible, and the quiz is configured to show correct answers; holders of quizzes:read or quizzes:grade over the quiz always may, with the rubric and the machine's suggestion on manually graded questions. Questions render as they are *now* — an edit after the sitting changes the review, never the recorded grades. The same paper downloads as a file from the sibling review.csv and review.json paths.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"responses":{"200":{"description":"The graded paper, answers included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptReview"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"Review is not (or not yet) available to the taker for this quiz.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}":{"get":{"tags":["quiz-taking"],"summary":"Read an attempt","description":"The attempt and its paper, answers stripped — the taking and resume view. Readable by the taker themselves, and by holders of quizzes:read over the quiz. An attempt past its deadline is finalized (auto-submitted as-is) by this read. Held-back results are nulled for the taker until released; permission holders always see them.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"responses":{"200":{"description":"The attempt and its paper.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptDetail"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/answers":{"post":{"tags":["quiz-taking"],"summary":"Answer one question","description":"Saves (or replaces) the taker's answer to one presented question, in the shape its kind takes: response_boolean (true_false), selected_option_ids (multiple_choice takes exactly one id; multi_select the chosen ids; ordering every presented item in the taker's arrangement), response_text (short_answer), response_number (numeric), response_matches (matching; partial pair lists are fine), response_blanks (fill_in_blank; one entry per blank, empty ones allowed) or response_drawing (drawing; the canvas as an image data URL). Only the taker may answer, only while the attempt is in progress and its deadline has not passed. Answers are not graded here; grading happens at submit.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnswerQuizQuestionRequest"}}}},"responses":{"200":{"description":"The attempt with the saved answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptDetail"}}}},"400":{"description":"The answer does not fit the question's kind or options.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id, or the question is not on its paper.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The attempt is already submitted, or its deadline has passed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/submit":{"post":{"tags":["quiz-taking"],"summary":"Submit an attempt","description":"Grades the attempt server-side, scores it against the quiz's pass mark, and reports it into the taker's learning record for the quiz. Unanswered questions score zero. A paper holding a manually graded question lands in pending_grading instead: a marker awards its points and finalizes it, and an administrator releases the result. Submitting twice is safe — the second call returns the attempt as it stands. Score and pass/fail travel back only when the quiz's rules make results visible.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"responses":{"200":{"description":"The submitted attempt; results nulled while withheld.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttempt"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/grading-queue":{"get":{"tags":["quiz-grading"],"summary":"List attempts awaiting grading","description":"Every attempt in pending_grading across the quizzes the caller's quizzes:grade scope reaches, oldest submission first, one page at a time; `quiz` narrows it to one quiz. Each row says how many manually graded questions the paper holds and how many still want points — zero means the attempt is ready to finalize. A caller holding no quizzes:grade anywhere gets an empty list, not an error.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"Only attempts of this quiz (UUID id)."},"required":false,"description":"Only attempts of this quiz (UUID id).","name":"quiz","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of the queue, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GradingQueueList"}}}},"400":{"description":"The cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/grading":{"get":{"tags":["quiz-grading"],"summary":"Read an attempt for grading","description":"The attempt's paper as a marker sees it: every question with the taker's response, the correct answer where the kind has one, the rubric, and on manually graded questions the machine's suggestion, the points awarded so far and the feedback written. For holders of quizzes:grade or quizzes:read over the quiz; the taker reads their own paper through the review endpoint instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"responses":{"200":{"description":"The paper, marker fields included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptReview"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/grades/{question_id}":{"put":{"tags":["quiz-grading"],"summary":"Award points on one question","description":"Records the marker's points — and, when sent, feedback for the taker — on one manually graded question of a submitted attempt. Full marks records the question as correct. While the attempt awaits finalization the grade is a draft the taker cannot see; on an already finalized attempt the change rescores it at once, and if its results were released the learning record gains a further score row labelled as a regrade. Requires quizzes:grade over the quiz.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of a question on the attempt's paper."},"required":true,"description":"UUID id of a question on the attempt's paper.","name":"question_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GradeQuestionRequest"}}}},"responses":{"200":{"description":"The paper with the grade recorded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptReview"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:grade.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The attempt is still in progress, the question is not on its paper or not manually graded, or the points exceed what the question is worth.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-attempts/{attempt_id}/finalize-grading":{"post":{"tags":["quiz-grading"],"summary":"Finalize an attempt's grading","description":"Scores a pending_grading attempt from its points once every manually graded question has some, and moves it to submitted. Nothing is released by this: the taker sees the result only once an administrator releases the attempt. Requires quizzes:grade over the quiz.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"responses":{"200":{"description":"The scored paper.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAttemptReview"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:grade.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible attempt has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The attempt is not awaiting grading, or a question still wants points.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/release-attempts":{"post":{"tags":["quiz-grading"],"summary":"Release chosen attempts' results","description":"Stamps results_released_at on the named attempts of the quiz, making each taker's score and pass/fail visible to them and — for attempts that needed a marker — reporting the score into their learning record. Only scored (submitted), unreleased attempts of this quiz are stamped; the rest of the list is skipped and the response says which ids went. Expired in-progress attempts are finalized first. Requires quizzes:write over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseAttemptsRequest"}}}},"responses":{"200":{"description":"Which attempts were released.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReleaseAttemptsResponse"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/attempts/export.csv":{"get":{"tags":["quizzes"],"summary":"Download a quiz's answers as CSV","description":"Every attempt of the quiz, newest first, flattened to one CSV row per presented question with the columns `attempt_id`, `quiz_id`, `quiz_name`, `user_id`, `user_display_name`, `attempt_number`, `status`, `started_at`, `submitted_at`, `auto_submitted`, `score_percentage`, `passed`, `position`, `question_id`, `kind`, `prompt`, `response`, `correct_answer`, `is_correct`, `points_awarded`, `points_possible`, `feedback`, `answered_at` — the attempt's columns repeated on each of its rows, so the file filters by taker, attempt or question in a spreadsheet. Lists in a cell are `; `-separated, alternative accepted spellings `|`-separated, matched pairs read `left → right`, and the file opens with a UTF-8 byte order mark. For holders of quizzes:read over the quiz's owner. Expired in-progress attempts are finalized first, as the results listing does. Pass `user` to export one taker's attempts only. At most 500 attempts travel; when more match, the export is refused outright rather than truncated — filter by taker.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only attempts by this taker (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":false,"description":"Only attempts by this taker (`users.id`).","name":"user","in":"query"}],"responses":{"200":{"description":"The CSV file, served as an attachment named `<quiz slug>-answers-<date>.csv`.","content":{"text/csv":{"schema":{"type":"string"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"More attempts match than one export may carry; nothing is exported.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/attempts/export.json":{"get":{"tags":["quizzes"],"summary":"Download a quiz's answers as JSON","description":"Every attempt of the quiz, newest first, each with its graded paper in the review endpoint's shape, as one JSON attachment. For holders of quizzes:read over the quiz's owner. Expired in-progress attempts are finalized first, as the results listing does. Pass `user` to export one taker's attempts only. At most 500 attempts travel; when more match, the export is refused outright rather than truncated — filter by taker.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","format":"uuid","description":"Only attempts by this taker (`users.id`).","example":"8f14e45f-ceea-467f-a10e-cbb9f6bcaf0f"},"required":false,"description":"Only attempts by this taker (`users.id`).","name":"user","in":"query"}],"responses":{"200":{"description":"The JSON file, served as an attachment named `<quiz slug>-answers-<date>.json`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAnswersExport"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"More attempts match than one export may carry; nothing is exported.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/schedules":{"post":{"tags":["quizzes"],"summary":"Schedule a sitting","description":"Adds one scheduled sitting of the quiz — live quizzes are takeable only inside an open sitting, and max_attempts counts per sitting, so recurring runs (say, yearly recertification) are just more sittings. Sittings of one quiz must not overlap. Sittings of async quizzes are accepted but ignored. The audience says who may sit it: every assignee, or only the roster kept at /quiz-schedules/{schedule_id}/participants. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateScheduledQuizRequest"}}}},"responses":{"201":{"description":"The scheduled sitting.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledQuiz"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The sitting would overlap another sitting of the quiz.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-schedules/{schedule_id}":{"patch":{"tags":["quizzes"],"summary":"Update a sitting","description":"Changes a sitting's label, window or audience. Attempts already sat in it keep their recorded deadlines and grades; moving the close changes when their held-back results release. Requires quizzes:write over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the scheduled sitting."},"required":true,"description":"UUID id of the scheduled sitting.","name":"schedule_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateScheduledQuizRequest"}}}},"responses":{"200":{"description":"The updated sitting.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledQuiz"}}}},"400":{"description":"The request body failed validation, or the merged window is inverted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this sitting but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible sitting has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The sitting would overlap another sitting of the quiz.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["quizzes"],"summary":"Delete a sitting","description":"Removes a sitting nobody has sat. A sitting with attempts is history and answers 409 — move its window instead. Requires quizzes:write over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the scheduled sitting."},"required":true,"description":"UUID id of the scheduled sitting.","name":"schedule_id","in":"path"}],"responses":{"200":{"description":"The deleted sitting, as it was.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledQuiz"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this sitting but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible sitting has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The sitting has attempts; it is history and cannot be deleted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-schedules/{schedule_id}/participants":{"get":{"tags":["quizzes"],"summary":"List a sitting's roster","description":"The trainees on the sitting's roster, oldest entry first. Decides who may sit a roster sitting; ignored while the sitting's audience is assignees. Requires quizzes:read over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the scheduled sitting."},"required":true,"description":"UUID id of the scheduled sitting.","name":"schedule_id","in":"path"}],"responses":{"200":{"description":"The roster.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledQuizParticipantList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible sitting has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["quizzes"],"summary":"Add a trainee to a sitting's roster","description":"Puts one assignee of the quiz on the sitting's roster. The member must be assigned the quiz already — individually, or through a role they hold — since a roster only narrows who may sit this sitting. Requires quizzes:write over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the scheduled sitting."},"required":true,"description":"UUID id of the scheduled sitting.","name":"schedule_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddScheduledQuizParticipantRequest"}}}},"responses":{"201":{"description":"The roster entry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledQuizParticipant"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this sitting but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible sitting has that id, or the member is not in the quiz's organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The member is not assigned the quiz, or is already on the roster.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-schedule-participants/{participant_id}":{"delete":{"tags":["quizzes"],"summary":"Remove a trainee from a sitting's roster","description":"Takes one trainee off the sitting's roster. Attempts they already sat in it are history and stay. Requires quizzes:write over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the roster entry."},"required":true,"description":"UUID id of the roster entry.","name":"participant_id","in":"path"}],"responses":{"200":{"description":"The removed entry, as it was (display fields omitted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScheduledQuizParticipant"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this sitting but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible roster entry has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/assignments":{"get":{"tags":["quizzes"],"summary":"List a quiz's assignments","description":"Who may see and take the quiz: assigned department roles (every holder) and individually assigned members. Requires quizzes:read over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The quiz's assignments, oldest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAssignmentList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["quizzes"],"summary":"Assign a quiz","description":"Assigns the quiz to a department role (every holder can take it) or to one directory member — exactly one of role_id / user_id. Both must belong to the quiz's own organization. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuizAssignmentRequest"}}}},"responses":{"201":{"description":"The created assignment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAssignment"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The quiz, role, or member does not exist in the quiz's organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The quiz is already assigned to that role or member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quiz-assignments/{assignment_id}":{"delete":{"tags":["quizzes"],"summary":"Remove an assignment","description":"Unassigns a role or member from the quiz. Attempts already made are history and stay. Requires quizzes:write over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the assignment."},"required":true,"description":"UUID id of the assignment.","name":"assignment_id","in":"path"}],"responses":{"200":{"description":"The removed assignment, as it was (display names omitted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizAssignment"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible assignment has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/banks":{"put":{"tags":["quizzes"],"summary":"Set a quiz's bank links","description":"Replaces the quiz's bank links; array order is presentation order, and draw_count bounds what a random_draw quiz pulls from each bank (omit for all). Banks must belong to the quiz's own organization. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetQuizBankLinksRequest"}}}},"responses":{"200":{"description":"The quiz with its new bank links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizDetail"}}}},"400":{"description":"The body failed validation, or a bank is not in the quiz's organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/subjects":{"put":{"tags":["quizzes"],"summary":"Set a quiz's subject matters","description":"Replaces the quiz's subject-matter tags — the subjects each taker's learning record is filed under. Subjects must belong to the quiz's own organization. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetQuizSubjectsRequest"}}}},"responses":{"200":{"description":"The quiz with its new subject tags.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizDetail"}}}},"400":{"description":"The body failed validation, or a subject is not in the quiz's organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/publish":{"post":{"tags":["quizzes"],"summary":"Publish a quiz","description":"Draft → published: assignees can now see the quiz, and sit it inside one of its scheduled sittings (live) or at any time (async). Refused while the linked banks hold no live questions, or while a live quiz has no sitting scheduled. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The published quiz.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quiz"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The quiz would present no questions, or a live quiz has no sitting.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/release-results":{"post":{"tags":["quizzes"],"summary":"Release held-back results","description":"Stamps results_released_at, making every taker's results visible — the manual release for quizzes that neither show results immediately nor have a closing window. Every scored, unreleased attempt is released per attempt at the same moment, manually graded ones included (their learning-record evidence is written now); attempts finalized later need a further release. Expired in-progress attempts are finalized first. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The quiz, with results released.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quiz"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}":{"delete":{"tags":["quizzes"],"summary":"Archive a quiz","description":"Soft-deletes a quiz by setting archived_at: it disappears from assignees and cannot be sat; attempts and their learning records stay readable. Requires quizzes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The archived quiz.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Quiz"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["quizzes"],"summary":"Read a quiz","description":"One quiz with its bank links, subject tags and scheduled sittings — the admin view. Requires quizzes:read over the owner. Takers read their assigned view at /quizzes/assigned.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The quiz, bank links and subjects included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizDetail"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["quizzes"],"summary":"Update a quiz","description":"Changes a quiz's configuration. Published quizzes stay editable — attempts are graded at submit time, so history never re-grades — but selection and limit changes apply to future attempts immediately. Sittings are managed at /quizzes/{quiz_id}/schedules. Requires quizzes:write over the owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQuizRequest"}}}},"responses":{"200":{"description":"The updated quiz, bank links and subjects included.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizDetail"}}}},"400":{"description":"The request body failed validation, or the window rules were broken.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a quiz with the new slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes/{quiz_id}/duplicate":{"post":{"tags":["quizzes"],"summary":"Duplicate a quiz","description":"Creates a new draft quiz with the original's configuration, bank links (with their draw counts) and subject tags. Assignments, sittings, attempts and any results release stay with the original; the banks are shared, not copied. The copy is named after the original with \" (copy)\" appended and takes the first free -copy slug unless name and slug are given. Requires quizzes:write over the quiz's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"annual-fire-safety"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"quiz_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateQuizRequest"}}}},"responses":{"201":{"description":"The copy, in draft, with its bank links and subjects.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizDetail"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this quiz but does not hold quizzes:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible quiz has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a quiz with the requested slug, or another copy took the generated one first — retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/quizzes":{"get":{"tags":["quizzes"],"summary":"List quizzes","description":"Lists the quizzes the caller can administer or read, newest first, with cursor pagination. Requires quizzes:read in scope; rows outside the caller's scope are simply absent. Takers list what is assigned to them at /quizzes/assigned instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of quizzes, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizList"}}}},"400":{"description":"A filter did not resolve, or the cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["quizzes"],"summary":"Create a quiz","description":"Creates a quiz in draft, owned by an organization or one department within it. Question banks of the owning organization can be linked from the start via bank_links (or later via PUT /quizzes/{quiz_id}/banks); tag subjects and assign takers next, then publish. live quizzes are sat inside scheduled sittings (POST /quizzes/{quiz_id}/schedules) — the same quiz can be scheduled again and again; async ones are takeable whenever published. Requires quizzes:write over the owner.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQuizRequest"}}}},"responses":{"201":{"description":"The created quiz, in draft, with any bank links it was created with.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuizDetail"}}}},"400":{"description":"The request body failed validation, or a requested bank is not in the owning organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold quizzes:write over the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, or the department within it, does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a quiz with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/mine":{"get":{"tags":["learning-pathways"],"summary":"List my learning pathways with progress","description":"Every unarchived pathway whose audience covers the caller — through a held role, a department they hold a grant in (or one above it), or organization membership — with every level's derived state. Needs no permission grant: the audience is the gate. Locked levels are presentation only; nothing is enforced server-side.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's learning pathways, each with derived progress.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyPathways"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/progress":{"get":{"tags":["learning-pathways"],"summary":"Get my progress on one pathway","description":"The pathway's level graph as it stands for the caller: which levels are complete, unlocked or locked, and where every certification stands. Readable by anyone the pathway's audience covers, and by holders of certifications:read over its owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The pathway as it stands for the caller.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayProgress"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/attachments/{attachment_id}/download":{"get":{"tags":["learning-pathways"],"summary":"Get a download URL for a pathway attachment","description":"Issues a short-lived presigned GET for the private attachment. Readable by anyone the pathway's audience covers, and by holders of certifications:read over its owner. Trainer resources are listed and downloadable by certifications:read holders only.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"Where to fetch the file from, for the next five minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownloadTicket"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The download URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/attachment-uploads":{"post":{"tags":["learning-pathways"],"summary":"Request an upload URL for a pathway attachment","description":"Issues a presigned URL to PUT one file of any type straight to private blob storage, scoped to this pathway, the declared content type and a 500 MB ceiling. Attach it with POST /learning-pathways/{pathway_id}/attachments and the returned pathname afterwards. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDescriptionUploadRequest"}}}},"responses":{"201":{"description":"Where to PUT the file, and the pathname the attachment will point at.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionUploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/attachments":{"post":{"tags":["learning-pathways"],"summary":"Attach an uploaded file to a pathway","description":"Creates the attachment at the end of the authored order, pointing at a pathname an upload ticket was issued for (after PUTting the file there). The content type is read back from the store, never from the request. Requires certifications:write. Trainer resources are listed and downloadable by certifications:read holders only.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePathwayAttachmentRequest"}}}},"responses":{"201":{"description":"The created attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayAttachment"}}}},"400":{"description":"The body failed validation, or the pathname was not uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That upload is already attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/attachments/{attachment_id}":{"patch":{"tags":["learning-pathways"],"summary":"Relabel or reorder a pathway attachment","description":"Changes the attachment's label (null or empty clears it back to the file name), its audience,, whether it shows on the page or downloads only, or its slot in the authored order. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePathwayAttachmentRequest"}}}},"responses":{"200":{"description":"The updated attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayAttachment"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["learning-pathways"],"summary":"Delete a pathway attachment","description":"Removes the attachment; the blob it pointed at is deleted best-effort. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"204":{"description":"The attachment is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathway-levels/{level_id}/attachments/{attachment_id}/download":{"get":{"tags":["learning-pathways"],"summary":"Get a download URL for a level attachment","description":"Issues a short-lived presigned GET for the private attachment. Readable by anyone the level's pathway's audience covers, and by holders of certifications:read over its owner. Trainer resources are listed and downloadable by certifications:read holders only.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"}],"responses":{"200":{"description":"Where to fetch the file from, for the next five minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownloadTicket"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The download URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathway-levels/{level_id}/attachment-uploads":{"post":{"tags":["learning-pathways"],"summary":"Request an upload URL for a level attachment","description":"Issues a presigned URL to PUT one file of any type straight to private blob storage, scoped to this level, the declared content type and a 500 MB ceiling. Attach it with POST /learning-pathway-levels/{level_id}/attachments and the returned pathname afterwards. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDescriptionUploadRequest"}}}},"responses":{"201":{"description":"Where to PUT the file, and the pathname the attachment will point at.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionUploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this level but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathway-levels/{level_id}/attachments":{"post":{"tags":["learning-pathways"],"summary":"Attach an uploaded file to a level","description":"Creates the attachment at the end of the authored order, pointing at a pathname an upload ticket was issued for (after PUTting the file there). The content type is read back from the store, never from the request. Requires certifications:write. Trainer resources are listed and downloadable by certifications:read holders only.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePathwayAttachmentRequest"}}}},"responses":{"201":{"description":"The created attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayAttachment"}}}},"400":{"description":"The body failed validation, or the pathname was not uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this level but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That upload is already attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathway-levels/{level_id}/attachments/{attachment_id}":{"patch":{"tags":["learning-pathways"],"summary":"Relabel or reorder a level attachment","description":"Changes the attachment's label (null or empty clears it back to the file name), its audience,, whether it shows on the page or downloads only, or its slot in the authored order. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePathwayAttachmentRequest"}}}},"responses":{"200":{"description":"The updated attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayAttachment"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this level but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["learning-pathways"],"summary":"Delete a level attachment","description":"Removes the attachment; the blob it pointed at is deleted best-effort. Requires certifications:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"}],"responses":{"204":{"description":"The attachment is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this level but lacks certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/job-aids":{"put":{"tags":["learning-pathways"],"summary":"Replace the job aids presented on a pathway","description":"Sets the complete, ordered list of job aids shown on the pathway's overview beside its handouts. Aids of the pathway's own organization only; each is shown only to viewers its own audience covers. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetLinkedJobAidsRequest"}}}},"responses":{"200":{"description":"The pathway's job aids after the change, in authored order.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/JobAidRef"}}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug, or an aid is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathway-levels/{level_id}/job-aids":{"put":{"tags":["learning-pathways"],"summary":"Replace the job aids presented on a level","description":"Sets the complete, ordered list of job aids shown on the level's card beside its handouts. Aids of the pathway's own organization only; each is shown only to viewers its own audience covers. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetLinkedJobAidsRequest"}}}},"responses":{"200":{"description":"The level's job aids after the change, in authored order.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/JobAidRef"}}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this level but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level has that id, or an aid is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/levels":{"post":{"tags":["learning-pathways"],"summary":"Add a level to a pathway","description":"Creates a level at the end of the authored order, with its phase, its prerequisite levels and phases, and its certification contents in one call. A certification type may appear in at most one level per pathway. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePathwayLevelRequest"}}}},"responses":{"201":{"description":"The created level, with its prerequisites and contents.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayLevel"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug, or a phase, prerequisite level or certification type does not belong to it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A certification type is already placed elsewhere in this pathway, the level would depend on its own phase, or the prerequisites would form a cycle.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathway-levels/{level_id}":{"patch":{"tags":["learning-pathways"],"summary":"Update a level","description":"Renames or re-describes a level, or moves it between phases (phase_id, null to ungroup); depends_on_level_ids and depends_on_phase_ids each replace its whole prerequisite set of that kind (rejected when the expanded edges would close a cycle, or the level would depend on its own phase), certification_type_ids its whole contents. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePathwayLevelRequest"}}}},"responses":{"200":{"description":"The updated level, with its prerequisites and contents.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayLevel"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level has that id, or a phase, prerequisite level or certification type does not belong to its pathway.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The prerequisites would form a cycle, the level would depend on its own phase, or a certification type is already placed elsewhere in this pathway.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["learning-pathways"],"summary":"Delete a level","description":"Removes the level, its edges and its contents; levels that depended on it simply lose that prerequisite. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the level."},"required":true,"description":"UUID id of the level.","name":"level_id","in":"path"}],"responses":{"204":{"description":"The level is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible level has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/phases":{"post":{"tags":["learning-pathways"],"summary":"Add a phase to a pathway","description":"Creates a phase — a grouping of the pathway's levels — with the phases it comes after. That ordering lays the phases out; it gates nothing. Levels join a phase through their own phase_id. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePathwayPhaseRequest"}}}},"responses":{"201":{"description":"The created phase, with the phases it comes after.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayPhase"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug, or a phase it would come after does not belong to it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathway-phases/{phase_id}":{"patch":{"tags":["learning-pathways"],"summary":"Update a phase","description":"Renames or re-describes a phase; depends_on_phase_ids replaces the whole set of phases it comes after (rejected when the ordering would close a cycle). Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the phase."},"required":true,"description":"UUID id of the phase.","name":"phase_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePathwayPhaseRequest"}}}},"responses":{"200":{"description":"The updated phase, with the phases it comes after and its levels.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayPhase"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible phase has that id, or a phase it would come after does not belong to its pathway.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The ordering would put a phase after itself.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["learning-pathways"],"summary":"Delete a phase","description":"Removes an empty phase. One that still holds levels, or that levels depend on, is refused until those are moved out or re-pointed; phases that came after it simply lose that predecessor. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the phase."},"required":true,"description":"UUID id of the phase.","name":"phase_id","in":"path"}],"responses":{"204":{"description":"The phase is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible phase has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The phase still holds levels, or levels still depend on it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/assignments":{"post":{"tags":["learning-pathways"],"summary":"Assign a pathway's audience","description":"Presents the pathway to holders of a role, to everyone with a role grant in a department (or one nested beneath it), or to every member of the organization. The audience is presentation only — it never adds a certification requirement. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePathwayAssignmentRequest"}}}},"responses":{"201":{"description":"The created assignment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayAssignment"}}}},"400":{"description":"The request body failed validation, or the kind/id pairing is off.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug, or the role or department is not in the pathway's organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The pathway already has that audience row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/assignments/{assignment_id}":{"delete":{"tags":["learning-pathways"],"summary":"Remove one audience row","description":"Withdraws the pathway from that role, department or organization audience. Requires certifications:write over the pathway's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the assignment."},"required":true,"description":"UUID id of the assignment.","name":"assignment_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"204":{"description":"The assignment is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug, or no such assignment on it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways":{"get":{"tags":["learning-pathways"],"summary":"List learning pathways","description":"Lists the learning pathways the caller can administer, newest first, with cursor pagination. Requires certifications:read in scope; rows outside the caller's scope are simply absent. Trainees list what is assigned to them at /learning-pathways/mine instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of learning pathways, and the cursor for the next.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayList"}}}},"400":{"description":"A filter did not resolve, or the cursor is malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["learning-pathways"],"summary":"Create a learning pathway","description":"Creates a pathway, owned by an organization or one department within it. A pathway arranges existing certification types into levels with prerequisite edges — a presentation overlay that never adds a requirement. Add levels and assign the audience next. Requires certifications:write over the owner.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePathwayRequest"}}}},"responses":{"201":{"description":"The created pathway, with no levels yet.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pathway"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold certifications:write over the owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The organization, or the department within it, does not exist.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a pathway with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}":{"get":{"tags":["learning-pathways"],"summary":"Get a pathway with its structure","description":"The pathway, its phases, every level with its phase, prerequisites, certification contents and job aids, and the audience — the authoring read. Requires certifications:read over the owner; trainees read their own derived view at /learning-pathways/{pathway_id}/progress instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The pathway, its phases and levels, and its audience.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PathwayDetail"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["learning-pathways"],"summary":"Update a pathway","description":"Renames, re-slugs or re-describes a pathway. File attachments are managed through the /learning-pathways/{pathway_id}/attachments endpoints. Requires certifications:write over the owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePathwayRequest"}}}},"responses":{"200":{"description":"The updated pathway.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pathway"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a pathway with the new slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/learning-pathways/{pathway_id}/archive":{"post":{"tags":["learning-pathways"],"summary":"Archive a pathway","description":"Retires the pathway: it stays readable to administrators but leaves every trainee's pathway list. Idempotent. Requires certifications:write over the owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"new-hire-path"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"pathway_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The archived pathway.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Pathway"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this pathway but does not hold certifications:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible pathway has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/servers":{"get":{"tags":["external-lms"],"summary":"List external LMS servers","description":"Lists one organization's external LMS servers, alphabetically. Requires external-lms:read over the organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug whose catalogue to list. Required."},"required":true,"description":"Organization UUID id or slug whose catalogue to list. Required.","name":"org","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"}],"responses":{"200":{"description":"The organization's servers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsServerList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["external-lms"],"summary":"Add an external LMS server","description":"Adds a server to an organization's catalogue. Descriptive metadata only — the LRS never connects to it. Requires external-lms:write over the organization.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExternalLmsServerRequest"}}}},"responses":{"201":{"description":"The created server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsServer"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold external-lms:write over the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The organization already has a server with that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/servers/{server_id}/grades/import":{"post":{"tags":["external-lms"],"summary":"Import course grades from a CSV file","description":"Bulk variant of the grades import: one uploaded gradebook CSV instead of a JSON batch, with the same one-outcome-per-row semantics — malformed cells fail their row, not the file, so a fixed export can simply be uploaded again (already-imported rows come back as duplicate). Requires records:write over the organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/ExternalGradesImportForm"}}}},"responses":{"200":{"description":"One outcome per data row, in file order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestExternalGradesResult"}}}},"400":{"description":"The file is not parseable CSV, a required column is missing from the header, the file has no (or too many) data rows, or the server is archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold records:write over the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"413":{"description":"The file is larger than 1 MB.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/servers/{server_id}/grades":{"post":{"tags":["external-lms"],"summary":"Import course grades","description":"Imports a batch of grades from this server. Each created grade becomes a completed learning record (owned by the organization) carrying the score as evidence, plus a queryable grade row. Rows succeed or fail one by one — re-submitting a batch after fixing a mapping is the normal workflow; already-imported rows come back as duplicate. Learners are identified by external_user_id through the server's user mappings, or directly by user_id. Requires records:write over the organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestExternalGradesRequest"}}}},"responses":{"200":{"description":"One outcome per submitted row, in row order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestExternalGradesResult"}}}},"400":{"description":"The request body failed validation, or the server is archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold records:write over the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["external-lms"],"summary":"List recently imported grades","description":"The server's most recently imported grades, newest first. Requires external-lms:read over the server's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"The grades, newest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalCourseGradeList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/servers/{server_id}/user-mappings":{"get":{"tags":["external-lms"],"summary":"List a server's user mappings","description":"Lists how the server's external user identities resolve to directory members. Requires external-lms:read over the server's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The server's mappings, by external user id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsUserMappingList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"put":{"tags":["external-lms"],"summary":"Create or repoint a user mapping","description":"Maps one external user identity to a directory member; writing an existing identity repoints it. Grades already imported keep the member they resolved to at import time. Requires external-lms:write over the server's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertExternalLmsUserMappingRequest"}}}},"responses":{"200":{"description":"The server's mappings after the write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsUserMappingList"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold external-lms:write over the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug, or the user is not in the organization's directory.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/servers/{server_id}/user-mappings/{external_user_id}":{"delete":{"tags":["external-lms"],"summary":"Delete a user mapping","description":"Unmaps one external user identity; grades already imported through it are untouched. Requires external-lms:write over the server's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"The LMS's own identity of the learner, URL-encoded.","example":"jdoe"},"required":true,"description":"The LMS's own identity of the learner, URL-encoded.","name":"external_user_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The server's mappings after the delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsUserMappingList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold external-lms:write over the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug, or no mapping has that identity.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/servers/{server_id}/courses":{"get":{"tags":["external-lms"],"summary":"List a server's courses","description":"Lists one server's catalogue courses, alphabetically. Requires external-lms:read over the server's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"}],"responses":{"200":{"description":"The server's courses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsCourseList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["external-lms"],"summary":"Add a course to a server's catalogue","description":"Adds one course, keyed by the LMS's own course identifier — the id grades arrive under. Requires external-lms:write over the server's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExternalLmsCourseRequest"}}}},"responses":{"201":{"description":"The created course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsCourse"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold external-lms:write over the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug, or a subject matter is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The server already catalogues a course with that external id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/courses/{course_id}/permanent-deletion":{"get":{"tags":["external-lms"],"summary":"Preview permanently deleting a catalogue course","description":"Counts everything DELETE on this path would remove: the imported grades (with the learning records the import created) and the proof rules requiring a pass of the course. Nothing is changed. Requires external-lms:delete over the organization — a grant separate from external-lms:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the catalogue course."},"required":true,"description":"UUID id of the catalogue course.","name":"course_id","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this course but does not hold external-lms:delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["external-lms"],"summary":"Permanently delete a catalogue course","description":"Hard-deletes the course — unlike DELETE /external-lms/courses/{course_id}, which only archives. Every imported grade is deleted together with the learning record the import created for it, so passes that satisfied certification rules no longer count; the external_course_pass rules pointing at the course are deleted too. Irreversible. Preview the cost with GET first. Requires external-lms:delete over the organization — a grant separate from external-lms:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the catalogue course."},"required":true,"description":"UUID id of the catalogue course.","name":"course_id","in":"path"}],"responses":{"204":{"description":"The course, its grades, their records and its rules are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this course but does not hold external-lms:delete.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/servers/{server_id}":{"get":{"tags":["external-lms"],"summary":"Read one external LMS server","description":"Requires external-lms:read over the server's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsServer"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["external-lms"],"summary":"Update an external LMS server","description":"Changes a server's name, description, URL or kind. The slug is immutable. Requires external-lms:write over the organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateExternalLmsServerRequest"}}}},"responses":{"200":{"description":"The updated server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsServer"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this server but does not hold external-lms:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["external-lms"],"summary":"Archive an external LMS server","description":"Soft-deletes a server by setting archived_at. Its courses, mappings and imported grades stay readable, but the server stops taking grades. Requires external-lms:write over the organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","example":"moodle-eu"},"required":true,"description":"UUID id, or a slug resolved within the organization given by the org query parameter.","name":"server_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"}],"responses":{"200":{"description":"The archived server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsServer"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this server but does not hold external-lms:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible server has that id or slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/external-lms/courses/{course_id}":{"get":{"tags":["external-lms"],"summary":"Read one catalogue course","description":"Requires external-lms:read over the course's organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the catalogue course."},"required":true,"description":"UUID id of the catalogue course.","name":"course_id","in":"path"}],"responses":{"200":{"description":"The course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsCourse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["external-lms"],"summary":"Update a catalogue course","description":"Changes a course's name, description, URL, pass mark or subject tags (replaced as a set). The external course id is immutable — grades key on it. Requires external-lms:write over the organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the catalogue course."},"required":true,"description":"UUID id of the catalogue course.","name":"course_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateExternalLmsCourseRequest"}}}},"responses":{"200":{"description":"The updated course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsCourse"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this course but does not hold external-lms:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id, or a subject matter is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["external-lms"],"summary":"Archive a catalogue course","description":"Soft-deletes a course by setting archived_at. Imported grades stay, but the course stops taking new ones. Requires external-lms:write over the organization.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the catalogue course."},"required":true,"description":"UUID id of the catalogue course.","name":"course_id","in":"path"}],"responses":{"200":{"description":"The archived course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExternalLmsCourse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this course but does not hold external-lms:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/available":{"get":{"tags":["scorm-taking"],"summary":"List the courses the caller can take","description":"Lists the live SCORM courses the caller may take, each with their own attempt state: organization-owned courses of every organization whose directory names them, department-owned courses of departments they hold a role in, and trainers-only courses their scorm:read covers. Needs no permission grant for the first two: the course's audience is the authorization.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's available courses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailableScormCourseList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/{course_id}/attempts":{"post":{"tags":["scorm-taking"],"summary":"Launch a course (start or resume an attempt)","description":"Prepares a session of the caller's attempt: resumes the attempt in progress (its saved CMI data seeds the runtime, and its accumulated time rolls forward as the new session's base), or starts attempt n+1 when none is open — immediately with restart: true, which closes the open attempt first. The response carries everything the in-page runtime needs, including the signed content URL the module's iframe loads. An attempt runs against the package version it was launched with — a resumed attempt keeps its version even after the course's package was replaced; only a fresh attempt starts on the current one. Needs no permission grant: the course's audience is the authorization — organization membership, narrowed to the owning department's role holders for department-owned courses, or scorm:read over the owner for trainers-only courses.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LaunchScormCourseRequest"}}}},"responses":{"200":{"description":"The attempt already in progress, resumed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormLaunch"}}}},"201":{"description":"A freshly started attempt.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormLaunch"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No course available to the caller has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"A concurrent launch is already starting an attempt; retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"get":{"tags":["scorm"],"summary":"List a course's attempts","description":"Lists every learner's attempts of one course, newest first, one page at a time — each joined with the learner's current profile. Requires scorm:read over the course's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"}],"responses":{"200":{"description":"One page of the course's attempts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormCourseAttemptList"}}}},"400":{"description":"The cursor is not one this API issued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-attempts/{attempt_id}":{"put":{"tags":["scorm-taking"],"summary":"Commit an attempt's CMI data","description":"Saves what the module has reported: the full CMI element map replaces the attempt's saved copy, and the summary the row carries — completion, success, scores, location, accumulated time — is re-derived from it server-side. The first commit that reports the attempt complete also files the result into the learner's records. The runtime calls this on every LMSCommit/Commit and on LMSFinish/Terminate. Only the attempt's own learner may commit; not their attempt is indistinguishable from a missing one.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the attempt."},"required":true,"description":"UUID id of the attempt.","name":"attempt_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CommitScormAttemptRequest"}}}},"responses":{"200":{"description":"The attempt with its freshly derived summary.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormAttempt"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No attempt of the caller's has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The attempt is closed; launch the course again for a fresh one.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-content/{ticket}/{asset_path}":{"get":{"tags":["scorm-taking"],"summary":"Serve a file from a course package","description":"Serves one file out of a SCORM course's package zip: the launch page the player's iframe loads, and every asset it references by relative URL. The signed ticket segment is the authorization — POST /scorm-courses/{course_id}/attempts mints it into content_url after checking the caller may take the course — so no bearer token is required (module asset requests cannot carry one). Tickets are scoped to one course and the package version the attempt was launched against, and expire with the session.","security":[],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Signed content ticket, scoped to one course and package version — the launch response's content_url carries it."},"required":true,"description":"Signed content ticket, scoped to one course and package version — the launch response's content_url carries it.","name":"ticket","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Package-relative path of the file, spanning multiple path segments (the launch page's relative asset URLs resolve here on their own)."},"required":true,"description":"Package-relative path of the file, spanning multiple path segments (the launch page's relative asset URLs resolve here on their own).","name":"asset_path","in":"path"}],"responses":{"200":{"description":"The file's bytes, served with the package entry's own content type."},"400":{"description":"The asset path is missing or malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The content ticket is invalid or has expired; relaunch the course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"The course is gone, or no file at that path exists in its package.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The course content could not be served.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"The course package could not be read from storage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/uploads":{"post":{"tags":["scorm"],"summary":"Mint an upload ticket for a SCORM package","description":"Issues a presigned upload URL for one SCORM package (.zip), bypassing the request-body limit by sending the bytes straight to the private blob store. The signed ticket pins a server-chosen pathname, zip content types and the package size cap. The staged upload becomes a course only through POST /scorm-courses, which validates the package first. Requires scorm:write over the intended owner — the organization, or the owning department within it when one is named.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormPackageUploadRequest"}}}},"responses":{"201":{"description":"The presigned upload ticket.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormPackageUploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:write over the intended owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department is not one of the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload ticket could not be issued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses":{"get":{"tags":["scorm"],"summary":"List SCORM courses","description":"Lists the hosted SCORM courses the caller's scorm:read scope covers, newest first, optionally filtered to one organization or one owning department within it. Learners list what they can take through GET /scorm-courses/available instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"}],"responses":{"200":{"description":"The visible courses.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormCourseList"}}}},"400":{"description":"The department filter was given without an org filter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department is not one of the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["scorm"],"summary":"Register an uploaded package as a course","description":"Completes a package upload: validates the staged zip (it must contain an imsmanifest.xml whose launch resource exists in the archive), captures the launch path, SCORM version, manifest objectives and mastery score, and creates the course — owned by the organization, or by the owning department named within it, tagged with the subjects named, and open to trainees unless audience says trainers only. Requires scorm:write over that owner. An invalid package is rejected and its staged blob deleted.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateScormCourseRequest"}}}},"responses":{"201":{"description":"The registered course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormCourse"}}}},"400":{"description":"The request body failed validation, no upload exists at the pathname, or the package is not a runnable SCORM module.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:write over the intended owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department or a subject matter is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That uploaded package is already registered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/{course_id}/subjects":{"put":{"tags":["scorm"],"summary":"Replace a course's subject tags","description":"Sets the complete set of subjects the course — and every completion record it reports from now on — is filed under. Records already written keep their tags. Subjects of the course's own organization only. Requires scorm:write over the course's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetScormCourseSubjectsRequest"}}}},"responses":{"200":{"description":"The course's subjects after the change, alphabetically.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ScormCourseSubject"}}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:write over the course's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id, or a subject is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/{course_id}/package-uploads":{"post":{"tags":["scorm"],"summary":"Mint an upload ticket for a replacement package","description":"Issues a presigned upload URL for the zip that will replace this course's package, exactly as POST /scorm-courses/uploads does for a new course: the bytes go straight to the private blob store under a server-chosen pathname. The staged upload becomes the course's package only through PUT /scorm-courses/{course_id}/package, which validates it first. Requires scorm:write over the course's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormPackageReplacementUploadRequest"}}}},"responses":{"201":{"description":"The presigned upload ticket.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormPackageUploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:write over the course's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload ticket could not be issued.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/{course_id}/package":{"put":{"tags":["scorm"],"summary":"Replace the course's package","description":"Makes a staged upload the course's next package version: the zip is validated like a new registration (an imsmanifest.xml whose launch resource exists), and its launch path, SCORM version, objectives and mastery score become what fresh attempts run. The course keeps its id, name, subjects, audience and every attempt on record, so nothing that points at it — proof rules, pathways, QR codes — changes; an attempt in progress keeps running, and resumes on, the version it was launched with, and every earlier version's package stays stored for that and for the record. requires_recertification decides what the replacement means for learners: true means only completions of this version or later satisfy scorm_course_complete rules from now on, so everyone certified on the course must complete it again; false leaves every earlier completion standing, for a typo or a broken link. An invalid package is rejected and its staged blob deleted. Requires scorm:write over the course's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplaceScormCoursePackageRequest"}}}},"responses":{"200":{"description":"The course, now serving the new version to fresh attempts.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormCourse"}}}},"400":{"description":"The request body failed validation, no upload exists at the pathname, or the package is not a runnable SCORM module.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:write over the course's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That uploaded package is already registered to a course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/{course_id}/package-versions":{"get":{"tags":["scorm"],"summary":"List a course's package versions","description":"Lists every package the course has served, newest first: the registered upload as version 1, then one per replacement through PUT /scorm-courses/{course_id}/package. Each says what its manifest declared, whether it required recertification, and whether it is the version fresh attempts launch against; an attempt names its own version as package_version_number. Requires scorm:read over the course's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"responses":{"200":{"description":"The course's package versions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormPackageVersionList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/{course_id}/permanent-deletion":{"get":{"tags":["scorm"],"summary":"Preview permanently deleting a SCORM course","description":"Counts everything DELETE on this path would remove: every learner's attempts (in-progress ones included), the learning records completions reported into, the proof rules requiring completion of the course, and the package. Nothing is changed. Requires scorm:delete over the course's owner — a grant separate from scorm:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:delete over the course's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["scorm"],"summary":"Permanently delete a SCORM course","description":"Hard-deletes the course — unlike archiving, which only hides it from learners. Every attempt is deleted, the learning records completions reported into go with their score and pass/fail evidence (so whatever they counted toward certification requirements disappears), the scorm_course_complete rules pointing at the course are deleted too, and the package is removed from the file store. Irreversible. Preview the cost with GET first. Requires scorm:delete over the course's owner — a grant separate from scorm:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"responses":{"204":{"description":"The course, its attempts, their records, its rules and the package are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:delete over the course's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/scorm-courses/{course_id}":{"get":{"tags":["scorm"],"summary":"Read one SCORM course","description":"Reads one course. Requires scorm:read over the course's owner — its owning department when it has one, its organization otherwise.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"responses":{"200":{"description":"The course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormCourse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["scorm"],"summary":"Edit or archive a course","description":"Changes a course's name, description or audience (trainees, or trainers only), or archives/restores it. The package is replaced whole through PUT /scorm-courses/{course_id}/package, and the subject tags as a set through PUT /scorm-courses/{course_id}/subjects. Attempts are history, so there is no delete; archiving hides the course from learners. Requires scorm:write over the course's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the SCORM course."},"required":true,"description":"UUID id of the SCORM course.","name":"course_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateScormCourseRequest"}}}},"responses":{"200":{"description":"The updated course.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScormCourse"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold scorm:write over the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible course has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/available":{"get":{"tags":["job-aids"],"summary":"List the job aids that reach the caller","description":"Lists the live job aids the caller may read, each with their own view standing: organization-owned aids of every organization whose directory names them, department-owned aids of departments they hold a role in (or one nested beneath), and trainers-only aids their job-aids:read covers. Needs no permission grant for the first two: the aid's audience is the authorization.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's job aids.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailableJobAidList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids":{"get":{"tags":["job-aids"],"summary":"List job aids","description":"Lists the job aids the caller's job-aids:read scope covers, newest first, optionally filtered to one organization or one owning department within it. Readers list what reaches them through GET /job-aids/available instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"}],"responses":{"200":{"description":"The visible job aids.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAidList"}}}},"400":{"description":"The department filter was given without an org filter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department is not one of the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["job-aids"],"summary":"Create a job aid","description":"Creates a job aid — owned by the organization, or by the owning department named within it, tagged with the subjects named, and open to trainees unless audience says trainers only. Files are attached afterwards through POST /job-aids/{job_aid_id}/attachment-uploads and /attachments, links through POST /job-aids/{job_aid_id}/links. Requires job-aids:write over that owner.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJobAidRequest"}}}},"responses":{"201":{"description":"The created job aid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAid"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:write over the intended owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department or a subject matter is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/attachments/{attachment_id}/download":{"get":{"tags":["job-aids"],"summary":"Get a download URL for a job aid attachment","description":"Issues a short-lived presigned GET for the private attachment. Readable by anyone the job aid reaches while it is live, and by holders of job-aids:read over its owner. Issuing the URL records a view of this file.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"}],"responses":{"200":{"description":"Where to fetch the file from, for the next six hours — long enough to play a video through.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DownloadTicket"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The download URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/attachments/{attachment_id}/file":{"put":{"tags":["job-aids"],"summary":"Replace a job aid attachment's file","description":"Points the attachment at a newly uploaded file — a pathname an upload ticket was issued for, after PUTting the file there — keeping its id, label, slot and display choice, so everything that names the attachment still does. The previous file is deleted best-effort; the content type is read back from the store, never from the request. requires_recertification says whether the job aid must be read again by everyone certified on it (a compliance change) or the reads on record still count (a typo fix). Requires job-aids:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplaceRecertifiableAttachmentFileRequest"}}}},"responses":{"200":{"description":"The attachment, now serving the new file.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionAttachment"}}}},"400":{"description":"The body failed validation, or the pathname was not uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this job aid but lacks job-aids:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That upload is already attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/attachment-uploads":{"post":{"tags":["job-aids"],"summary":"Request an upload URL for a job aid attachment","description":"Issues a presigned URL to PUT one file of any type straight to private blob storage, scoped to this job aid, the declared content type and a 500 MB ceiling. Attach it with POST /job-aids/{job_aid_id}/attachments and the returned pathname afterwards. Requires job-aids:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDescriptionUploadRequest"}}}},"responses":{"201":{"description":"Where to PUT the file, and the pathname the attachment will point at.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionUploadTicket"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this job aid but lacks job-aids:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The upload URL could not be signed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/attachments":{"post":{"tags":["job-aids"],"summary":"Attach an uploaded file to a job aid","description":"Creates the attachment at the end of the authored order, pointing at a pathname an upload ticket was issued for (after PUTting the file there). The content type is read back from the store, never from the request. Requires job-aids:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDescriptionAttachmentRequest"}}}},"responses":{"201":{"description":"The created attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionAttachment"}}}},"400":{"description":"The body failed validation, or the pathname was not uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this job aid but lacks job-aids:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid matches the path.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That upload is already attached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/attachments/{attachment_id}":{"patch":{"tags":["job-aids"],"summary":"Relabel or reorder a job aid attachment","description":"Changes the attachment's label (null or empty clears it back to the file name), whether it shows on the page or downloads only, or its slot in the authored order. Requires job-aids:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDescriptionAttachmentRequest"}}}},"responses":{"200":{"description":"The updated attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DescriptionAttachment"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this job aid but lacks job-aids:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["job-aids"],"summary":"Delete a job aid attachment","description":"Removes the attachment; the blob it pointed at is deleted best-effort. Requires job-aids:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the attachment."},"required":true,"description":"UUID id of the attachment.","name":"attachment_id","in":"path"}],"responses":{"204":{"description":"The attachment is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can read this job aid but lacks job-aids:write.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid matches the path, or it has no such attachment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/links":{"post":{"tags":["job-aids"],"summary":"Add a link to a job aid","description":"Adds one http(s) link at the end of the authored order the aid's files and links share. Requires job-aids:write over the aid's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateJobAidLinkRequest"}}}},"responses":{"201":{"description":"The created link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAidLink"}}}},"400":{"description":"The request body failed validation, or the URL is not http(s).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:write over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/links/{link_id}":{"patch":{"tags":["job-aids"],"summary":"Edit or reorder a job aid link","description":"Changes the link's destination, its label (null or empty clears it back to the URL) or its slot in the authored order the aid's files share. Requires job-aids:write over the aid's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the link."},"required":true,"description":"UUID id of the link.","name":"link_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateJobAidLinkRequest"}}}},"responses":{"200":{"description":"The updated link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAidLink"}}}},"400":{"description":"The request body failed validation, or the URL is not http(s).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:write over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id, or it has no such link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["job-aids"],"summary":"Delete a job aid link","description":"Removes the link from the aid; what it pointed at is untouched. Requires job-aids:write over the aid's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"UUID id of the link."},"required":true,"description":"UUID id of the link.","name":"link_id","in":"path"}],"responses":{"204":{"description":"The link is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:write over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id, or it has no such link.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/subjects":{"put":{"tags":["job-aids"],"summary":"Replace a job aid's subject tags","description":"Sets the complete set of subjects the job aid is filed under. Subjects of the aid's own organization only. Requires job-aids:write over the aid's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetJobAidSubjectsRequest"}}}},"responses":{"200":{"description":"The aid's subjects after the change, alphabetically.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/JobAidSubject"}}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:write over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id, or a subject is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/views":{"post":{"tags":["job-aids"],"summary":"Record that the caller opened a job aid","description":"Records one open of the aid's page by the caller — what the aid's page does on every load, and what a job_aid_viewed proof rule counts. Never deduplicated: a refresh is another view. Opens of individual files are recorded by their download route instead. Allowed to anyone the aid reaches, and to job-aids:read holders over its owner; administrators' opens count like everyone else's.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"responses":{"201":{"description":"The recorded view.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAidView"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/analytics":{"get":{"tags":["job-aids"],"summary":"Read who has viewed a job aid","description":"Every recorded open of the aid, folded per person: how many times each opened it and when first and last, with the totals. Requires job-aids:read over the aid's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"responses":{"200":{"description":"The aid's view analytics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAidAnalytics"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/permanent-deletion":{"get":{"tags":["job-aids"],"summary":"Preview permanently deleting a job aid","description":"Counts everything DELETE on this path would remove: the proof rules requiring that the aid be viewed, every recorded view, the files, and the pathway placements. Nothing is changed. Requires job-aids:delete over the aid's owner — a grant separate from job-aids:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:delete over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["job-aids"],"summary":"Permanently delete a job aid","description":"Hard-deletes the job aid — unlike archiving, which only hides it. The job_aid_viewed rules pointing at it are deleted (so those certifications no longer require it), every recorded view goes, the pathway placements are removed, and the files are deleted from the store. Irreversible. Preview the cost with GET first. Requires job-aids:delete over the aid's owner — a grant separate from job-aids:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"responses":{"204":{"description":"The job aid, its rules, views, placements and files are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:delete over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}/duplicate":{"post":{"tags":["job-aids"],"summary":"Duplicate a job aid","description":"Creates a copy of a job aid: its summary, markdown body, audience, subject tags, links, and files — each file copied in the store, so deleting either aid later leaves the other's files alone. Views, the recertification cutoff, pathway placements and the proof rules naming the original stay with it, and the copy starts unarchived. The copy is titled after the original with \" (copy)\" appended unless a title is given. Requires job-aids:write over the aid's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateJobAidRequest"}}}},"responses":{"201":{"description":"The copy, with its subjects, files and links.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAid"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:write over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write or a file copy failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"The aid has files and file storage is not configured here.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/job-aids/{job_aid_id}":{"get":{"tags":["job-aids"],"summary":"Read one job aid","description":"Reads one job aid with its subjects, files and links. Readable by anyone the aid reaches — its audience, while it is live — and by holders of job-aids:read over its owner, archived or not. Reading here records no view; POST /job-aids/{job_aid_id}/views does.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"responses":{"200":{"description":"The job aid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAid"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["job-aids"],"summary":"Edit or archive a job aid","description":"Changes a job aid's title, summary, markdown body or audience (trainees, or trainers only), or archives/restores it. The subject tags are replaced as a set through PUT /job-aids/{job_aid_id}/subjects; the files through the attachment routes, the links through /job-aids/{job_aid_id}/links. Views are history, so there is no delete here; archiving hides the aid from its audience. require_recertification: true marks the edit as one everyone certified on the aid must read again — opens recorded before it stop satisfying job_aid_viewed rules. Requires job-aids:write over the aid's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the job aid."},"required":true,"description":"UUID id of the job aid.","name":"job_aid_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateJobAidRequest"}}}},"responses":{"200":{"description":"The updated job aid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobAid"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold job-aids:write over the aid's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible job aid has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/qr-codes":{"get":{"tags":["qr-codes"],"summary":"List QR codes","description":"Lists the QR codes the caller's qr-codes:read scope covers, newest first, each with its current target resolved, optionally filtered to one organization or one owning department within it.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"}],"responses":{"200":{"description":"The visible QR codes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeList"}}}},"400":{"description":"The department filter was given without an org filter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department is not one of the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["qr-codes"],"summary":"Create a QR code","description":"Creates a QR code — owned by the organization, or by the owning department named within it — pointing at one of the organization's job aids, SCORM courses or quizzes, or at a raw URL. The slug is the public URL segment the image encodes (/qr/{slug}); choose one or have one generated, and print it knowing it never changes — the target does, through PUT /qr-codes/{qr_code_id}/target. Requires qr-codes:write over that owner.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateQrCodeRequest"}}}},"responses":{"201":{"description":"The created QR code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCode"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold qr-codes:write over the intended owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department or the target resource is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"Another QR code already has that slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/qr-codes/{qr_code_id}/target":{"put":{"tags":["qr-codes"],"summary":"Point a QR code at a new destination","description":"Replaces where the code leads — one of the organization's job aids, SCORM courses or quizzes, or a raw URL — without changing the printed code. The previous target is logged with the label it has now, readable at GET /qr-codes/{qr_code_id}/target-changes. Requires qr-codes:write over the code's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the QR code."},"required":true,"description":"UUID id of the QR code.","name":"qr_code_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RetargetQrCodeRequest"}}}},"responses":{"200":{"description":"The QR code with its new target.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCode"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold qr-codes:write over the code's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible QR code has that id, or the target resource is not the code's organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/qr-codes/{qr_code_id}/target-changes":{"get":{"tags":["qr-codes"],"summary":"Read where a QR code has pointed","description":"Every retarget of the code, most recent first: who changed it and when, and the target before and after with the labels they carried at the time. The code's creation is not a change; the oldest entry's `from` is the target it was created with. Requires qr-codes:read over the code's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the QR code."},"required":true,"description":"UUID id of the QR code.","name":"qr_code_id","in":"path"}],"responses":{"200":{"description":"The code's target history.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeTargetChangeList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible QR code has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/qr-codes/{qr_code_id}/analytics":{"get":{"tags":["qr-codes"],"summary":"Read who has scanned a QR code","description":"Every recorded scan of the code, folded per person: how many times each scanned it, how many of those went through, and when first and last — with the totals by outcome. Scans are recorded only for signed-in people; a scanner who never signs in is not counted. Requires qr-codes:read over the code's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the QR code."},"required":true,"description":"UUID id of the QR code.","name":"qr_code_id","in":"path"}],"responses":{"200":{"description":"The code's scan analytics.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCodeAnalytics"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible QR code has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/qr-codes/{qr_code_id}/permanent-deletion":{"get":{"tags":["qr-codes"],"summary":"Preview permanently deleting a QR code","description":"Counts everything DELETE on this path would remove: every recorded scan and every logged destination change. Nothing is changed. Requires qr-codes:delete over the code's owner — a grant separate from qr-codes:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the QR code."},"required":true,"description":"UUID id of the QR code.","name":"qr_code_id","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold qr-codes:delete over the code's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible QR code has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["qr-codes"],"summary":"Permanently delete a QR code","description":"Hard-deletes the QR code — unlike archiving, which only makes scans answer unavailable. Every recorded scan and every logged destination change goes with it, and the printed code answers 404 from then on. Irreversible. Preview the cost with GET first. Requires qr-codes:delete over the code's owner — a grant separate from qr-codes:write.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the QR code."},"required":true,"description":"UUID id of the QR code.","name":"qr_code_id","in":"path"}],"responses":{"204":{"description":"The QR code, its scans and its history are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold qr-codes:delete over the code's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible QR code has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/qr-codes/{qr_code_id}":{"get":{"tags":["qr-codes"],"summary":"Read one QR code","description":"Reads one QR code with its current target resolved — the resource's name and whether a scan can reach it right now. Requires qr-codes:read over the code's owner; the public redirect at /qr/{slug} is a page, not part of this API.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the QR code."},"required":true,"description":"UUID id of the QR code.","name":"qr_code_id","in":"path"}],"responses":{"200":{"description":"The QR code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCode"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible QR code has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["qr-codes"],"summary":"Edit or archive a QR code","description":"Changes a QR code's name or note, or archives/restores it — an archived code answers unavailable to every scan while its history stays. The slug never changes; the target changes through PUT /qr-codes/{qr_code_id}/target. Requires qr-codes:write over the code's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the QR code."},"required":true,"description":"UUID id of the QR code.","name":"qr_code_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateQrCodeRequest"}}}},"responses":{"200":{"description":"The updated QR code.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QrCode"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold qr-codes:write over the code's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible QR code has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/mine":{"get":{"tags":["events"],"summary":"List the caller's own events","description":"Every unarchived event the caller is on — as a participant, a trainer, or both — in calendar order (unscheduled events last), each with their own attendance where they are a participant. Needs no permission grant: being on the event is the authorization. Cancelled events are included, flagged by cancelled_at.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"date-time","description":"Only events ending at or after this instant; unscheduled events are always included."},"required":false,"description":"Only events ending at or after this instant; unscheduled events are always included.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Only events starting before this instant (unscheduled events included)."},"required":false,"description":"Only events starting before this instant (unscheduled events included).","name":"to","in":"query"}],"responses":{"200":{"description":"The caller's events.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MyTrainingEventList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/batch":{"post":{"tags":["events"],"summary":"Create a series of training events","description":"Creates one event per window given, all owned by the same organization (or owning department within it), with the same title, venue, credit and subject tags — a recurring session scheduled for a term in one request. The trainers and participants named are put on every event of the series; each must be a member of the organization. Everything is written in one transaction, so a series is never half-created. Requires events:write over the owner. Answers the created events in calendar order.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTrainingEventBatchRequest"}}}},"responses":{"201":{"description":"The created events, earliest first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEventList"}}}},"400":{"description":"The request body failed validation, or a named trainer or participant is not a member of the organization.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the intended owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department or a subject matter is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/conflicts":{"post":{"tags":["events"],"summary":"Check for scheduling conflicts","description":"Says who among the given people is already on another live training event — as a participant or a trainer — during any of the given windows, so a double booking is seen before it is made. Cancelled, archived and unscheduled events never clash. Writes nothing: it is a POST only because the people and windows do not fit a query string. Requires events:write somewhere, since it serves the scheduling forms; the clash itself is reported wherever the other event lives, but that event's id and title are given only when the caller's events:read scope covers it.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckSchedulingConflictsRequest"}}}},"responses":{"200":{"description":"The clashes found, by window then by the other event's start; empty when none.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SchedulingConflictList"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller holds events:write nowhere.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events":{"get":{"tags":["events"],"summary":"List training events","description":"Lists the training events the caller's events:read scope covers, in calendar order (unscheduled events last), optionally filtered to one organization or one owning department within it, and to a time window. Archived events are left out unless asked for; cancelled ones are listed with their cancelled_at set. The events one is on as a participant or trainer are listed by GET /events/mine instead.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Only events ending at or after this instant. Unscheduled events (no time yet) are always included."},"required":false,"description":"Only events ending at or after this instant. Unscheduled events (no time yet) are always included.","name":"from","in":"query"},{"schema":{"type":"string","format":"date-time","description":"Only events starting before this instant (unscheduled events included)."},"required":false,"description":"Only events starting before this instant (unscheduled events included).","name":"to","in":"query"}],"responses":{"200":{"description":"The visible events.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEventList"}}}},"400":{"description":"The department filter was given without an org filter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department is not one of the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["events"],"summary":"Create a training event","description":"Creates an event owned by the organization, or by the owning department named within it, tagged with the given subjects. Requires events:write over that owner. Give starts_at and ends_at to schedule it, or neither for an event whose time is not yet known: it is listed as unscheduled, and attendance cannot be marked until it is given a window. Trainers and participants are added afterwards through the event's own routes.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTrainingEventRequest"}}}},"responses":{"201":{"description":"The created event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEvent"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the intended owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible organization has that id or slug, or the department or a subject matter is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/permanent-deletion":{"get":{"tags":["events"],"summary":"Preview permanently deleting an event","description":"Counts everything DELETE on this path would remove: the learning records attendance marks wrote (and the hours they credited), the roster and the trainers. Nothing is changed. Requires events:delete over the event's owner — a grant separate from events:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"responses":{"200":{"description":"What the deletion would take with it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeletionImpact"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:delete over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["events"],"summary":"Permanently delete an event","description":"Hard-deletes the event — unlike archiving (hides it) or cancelling (archives its records). The roster, trainers and subject tags go, and so do the learning records attendance marks produced, with their evidence: the hours they credited toward certification requirements disappear. Irreversible. Preview the cost with GET first. Requires events:delete over the event's owner — a grant separate from events:write, which only archives.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"responses":{"204":{"description":"The event, its roster and its attendance records are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:delete over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/duplicate":{"post":{"tags":["events"],"summary":"Duplicate a training event","description":"Creates a copy of an event — the next session of a recurring one — with the same owner, description, venue, meeting link, credit hours, subject tags and trainers. Participants, attendance and the learning records attendance wrote stay with the original, and the copy is neither cancelled nor archived. Give starts_at and ends_at to schedule the copy, or neither for an unscheduled one; the title is the original's unless given. Requires events:write over the event's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateTrainingEventRequest"}}}},"responses":{"201":{"description":"The copy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEvent"}}}},"400":{"description":"The request body failed validation, or the window is half set or out of order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller can see the event but does not hold events:write over its owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}":{"get":{"tags":["events"],"summary":"Read one training event","description":"Reads one event with its subject tags, trainers and participant roster (with attendance). Requires events:read over the event's owner — or being on the event as a participant or trainer.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"responses":{"200":{"description":"The event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEventDetail"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["events"],"summary":"Edit, cancel or archive an event","description":"Changes an event's title, description, times (set both to schedule an unscheduled event, null both to unschedule it), meeting link, location or credit hours; cancels it (final — attendance can no longer be marked, and the learning records earlier attendance marks produced are archived); or archives/restores it. Edits never touch records already written for attendance. Requires events:write over the event's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTrainingEventRequest"}}}},"responses":{"200":{"description":"The updated event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEvent"}}}},"400":{"description":"The request body failed validation, or the new window is out of order or half set.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The event is already cancelled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/subjects":{"put":{"tags":["events"],"summary":"Replace an event's subject tags","description":"Sets the complete set of subjects the event — and every attendance record it produces from now on — is filed under. Records already written keep their tags. Subjects of the event's own organization only. Requires events:write over the owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetTrainingEventSubjectsRequest"}}}},"responses":{"200":{"description":"The event's subjects after the change.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventSubject"}}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id, or a subject is not the organization's own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/participants":{"get":{"tags":["events"],"summary":"List an event's participants","description":"The roster with each participant's attendance and, for attendees, the learning record their attendance wrote. Same visibility as reading the event.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"responses":{"200":{"description":"The roster.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEventParticipantList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["events"],"summary":"Add a participant","description":"Puts a member of the event's organization on the roster as `registered`. Requires events:write over the event's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddTrainingEventPersonRequest"}}}},"responses":{"201":{"description":"The new roster row.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEventParticipant"}}}},"400":{"description":"The request body failed validation, or the user is not an organization member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That user is already on the roster.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/participants/{user_id}":{"delete":{"tags":["events"],"summary":"Remove a participant","description":"Takes a person off the roster. If their attendance had been marked, the learning record it wrote is archived — off the roster means off the count. Requires events:write over the event's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"`users.id` of the person on the event."},"required":true,"description":"`users.id` of the person on the event.","name":"user_id","in":"path"}],"responses":{"204":{"description":"Removed."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id, or the user is not on its roster.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/participants/{user_id}/attendance":{"put":{"tags":["events"],"summary":"Mark a participant's attendance","description":"Sets one participant's attendance once the event has started. `attended` writes a completed learning record for them — titled and owned like the event, filed under its subjects, with one evidence row worth its credit hours (or its duration) — which the subject-hours rules count immediately. Any other mark archives the record an earlier `attended` wrote. A cancelled or archived event, or one that has not started, takes no marks. Requires events:write over the event's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"`users.id` of the person on the event."},"required":true,"description":"`users.id` of the person on the event.","name":"user_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetTrainingEventAttendanceRequest"}}}},"responses":{"200":{"description":"The roster row after the mark.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrainingEventParticipant"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id, or the user is not on its roster.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The event has not started, or is cancelled or archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/trainers":{"post":{"tags":["events"],"summary":"Schedule a trainer","description":"Names a member of the event's organization as one of its trainers. Informational: being scheduled grants no permission over the event, though trainers can always read it. Requires events:write over the event's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddTrainingEventPersonRequest"}}}},"responses":{"200":{"description":"The event's trainers after the change.","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TrainingEventTrainer"}}}}},"400":{"description":"The request body failed validation, or the user is not an organization member.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"That user is already a trainer of the event.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/events/{event_id}/trainers/{user_id}":{"delete":{"tags":["events"],"summary":"Unschedule a trainer","description":"Removes a trainer from the event. Requires events:write over the event's owner.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the training event."},"required":true,"description":"UUID id of the training event.","name":"event_id","in":"path"},{"schema":{"type":"string","format":"uuid","description":"`users.id` of the person on the event."},"required":true,"description":"`users.id` of the person on the event.","name":"user_id","in":"path"}],"responses":{"204":{"description":"Removed."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold events:write over the event's owner.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No visible event has that id, or the user is not one of its trainers.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/forums":{"get":{"tags":["forums"],"summary":"List forums","description":"Lists the forums the caller may read, by organization and department, then by name — optionally narrowed to one organization or one department within it. A department's forums are read by everyone holding a role in it or in one nested beneath it, and by holders of forums:read, forums:write or forums:moderate over it. Archived forums are left out unless include_archived is true.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","example":"acme-corporation"},"required":false,"description":"Organization UUID id or slug to filter by (and to resolve slug refs in).","name":"org","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Department UUID id or slug, resolved within org (which is then required).","example":"quality-assurance"},"required":false,"description":"Department UUID id or slug, resolved within org (which is then required).","name":"department","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"Include archived rows. Defaults to false."},"required":false,"description":"Include archived rows. Defaults to false.","name":"include_archived","in":"query"}],"responses":{"200":{"description":"The readable forums.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumList"}}}},"400":{"description":"The department filter was given without an org filter.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No organization has that id or slug, or the department is not one of its own.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["forums"],"summary":"Create a forum","description":"Creates a forum in a department — a department may have several, each for its own kind of conversation. Its name is unique within the department, ignoring case. Requires forums:write over the department.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateForumRequest"}}}},"responses":{"201":{"description":"The new forum.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Forum"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller may read the department's forums but does not hold forums:write over it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No live organization or department has that ref, or the caller cannot read its forums.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The department already has a forum of that name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/forums/{forum_id}/topics":{"get":{"tags":["forums"],"summary":"List a forum's discussions","description":"Lists the discussions of one forum the caller may read, latest activity first, a page at a time.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the forum."},"required":true,"description":"UUID id of the forum.","name":"forum_id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Opaque cursor from a previous page's next_cursor."},"required":false,"description":"Opaque cursor from a previous page's next_cursor.","name":"cursor","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"description":"Page size, 1-200. Defaults to 50."},"required":false,"description":"Page size, 1-200. Defaults to 50.","name":"limit","in":"query"}],"responses":{"200":{"description":"One page of the forum's discussions.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumTopicList"}}}},"400":{"description":"The cursor is invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No forum the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["forums"],"summary":"Start a discussion in a forum","description":"Starts a discussion in a forum, optionally linking up to ten of the organization's live job aids, courses, quizzes and events — each one the caller can open themselves. The caller must take part in the forum's department — hold a role in it or in one nested beneath it, or forums:moderate over it — and the forum must be open.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the forum."},"required":true,"description":"UUID id of the forum.","name":"forum_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateForumTopicRequest"}}}},"responses":{"201":{"description":"The new discussion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumTopic"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller may read the forum (forums:read or forums:write) but not take part in it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No forum the caller may read has that id, or a linked resource is not one they can open.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The forum is archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/forums/{forum_id}":{"get":{"tags":["forums"],"summary":"Read one forum","description":"Reads a forum — its department, description, how many discussions it holds and when one last moved — and what the caller may do to it. Its discussions are listed through GET /forums/{forum_id}/topics.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the forum."},"required":true,"description":"UUID id of the forum.","name":"forum_id","in":"path"}],"responses":{"200":{"description":"The forum.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Forum"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No forum the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["forums"],"summary":"Rename, describe, archive or restore a forum","description":"Changes a forum's name or description, or archives it — its discussions stay readable, but nobody posts, replies or edits until it is restored. Requires forums:write over its department.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the forum."},"required":true,"description":"UUID id of the forum.","name":"forum_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateForumRequest"}}}},"responses":{"200":{"description":"The forum, as changed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Forum"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold forums:write over the forum's department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No forum the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The department already has a forum of that name.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["forums"],"summary":"Delete a forum","description":"Permanently deletes a forum with every discussion, reply and link in it. Archiving (PATCH) is the reversible way to close one. Requires forums:delete over its department.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the forum."},"required":true,"description":"UUID id of the forum.","name":"forum_id","in":"path"}],"responses":{"204":{"description":"The forum and everything in it are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold forums:delete over the forum's department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No forum the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the delete failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/forum-topics/{topic_id}/moderation":{"patch":{"tags":["forums"],"summary":"Pin or lock a forum discussion","description":"Pins a discussion to the top of its forum or unpins it, and locks it — only moderators may then reply, and nobody may edit — or unlocks it. Requires forums:moderate over the discussion's department.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the discussion."},"required":true,"description":"UUID id of the discussion.","name":"topic_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumTopicModerationRequest"}}}},"responses":{"200":{"description":"The discussion, as moderated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumTopic"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller does not hold forums:moderate over the forum's department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No discussion the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/forum-topics/{topic_id}/replies":{"post":{"tags":["forums"],"summary":"Reply to a forum discussion","description":"Adds a reply to a discussion and moves it to the top of its forum. The caller must take part in the forum's department — a role in it or in one nested beneath it, or forums:moderate over it — and the forum must be open; only moderators may reply to a locked discussion.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the discussion."},"required":true,"description":"UUID id of the discussion.","name":"topic_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateForumReplyRequest"}}}},"responses":{"201":{"description":"The new reply.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumReply"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller may read the forum (forums:read or forums:write) but not take part in it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No discussion the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The forum is archived, or the discussion is locked and the caller does not moderate it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/forum-topics/{topic_id}":{"get":{"tags":["forums"],"summary":"Read one forum discussion","description":"Reads a discussion with every reply, oldest first, the linked material the caller may open, and what the caller may do to the discussion and to each reply.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the discussion."},"required":true,"description":"UUID id of the discussion.","name":"topic_id","in":"path"}],"responses":{"200":{"description":"The discussion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumTopic"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No discussion the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["forums"],"summary":"Edit a forum discussion","description":"Changes a discussion's title, opening post or linked material, and marks it edited. Only its author may, while they take part in the forum's department and the discussion is unlocked; moderators cannot rewrite what someone else said. New links must be material the author can open; links they cannot open are kept.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the discussion."},"required":true,"description":"UUID id of the discussion.","name":"topic_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateForumTopicRequest"}}}},"responses":{"200":{"description":"The edited discussion.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumTopic"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not the discussion's author, or no longer takes part in the forum's department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No discussion the caller may read has that id, or a linked resource is not one they can open.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The discussion is locked, or the forum archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["forums"],"summary":"Delete a forum discussion","description":"Permanently deletes a discussion with every reply and link. Holders of forums:delete over its department may delete any discussion; its author may delete it until someone else has replied.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the discussion."},"required":true,"description":"UUID id of the discussion.","name":"topic_id","in":"path"}],"responses":{"204":{"description":"The discussion and its replies are gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is neither its author nor a holder of forums:delete over the forum's department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No discussion the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The caller is its author, but other people have replied to it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the delete failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/forum-replies/{reply_id}":{"patch":{"tags":["forums"],"summary":"Edit a forum reply","description":"Replaces a reply's text and marks it edited. Only its author may, while they take part in the forum's department, the discussion is unlocked and the forum open.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the reply."},"required":true,"description":"UUID id of the reply.","name":"reply_id","in":"path"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateForumReplyRequest"}}}},"responses":{"200":{"description":"The edited reply.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ForumReply"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not the reply's author, or no longer takes part in the forum's department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No reply the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The discussion is locked, or the forum archived.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["forums"],"summary":"Delete a forum reply","description":"Permanently deletes a reply. Its author may, and so may holders of forums:delete over the discussion's department.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","format":"uuid","description":"UUID id of the reply."},"required":true,"description":"UUID id of the reply.","name":"reply_id","in":"path"}],"responses":{"204":{"description":"The reply is gone."},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is neither its author nor a holder of forums:delete over the forum's department.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No reply the caller may read has that id.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be reached, or the delete failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/notifications/certification-requirements":{"post":{"tags":["notifications"],"summary":"Send a certification requirements notification email","description":"Builds a digest of certification requirements grouped into due windows (overdue, next 10/30/60 days), plus the training events upcoming in the same horizon, renders it to HTML + plaintext, validates it against the @schemavaults/send-email request schema, and submits it to the configured transactional mail server. `audience: trainee` covers the user's own requirements; `audience: manager` covers the trainees holding roles the user manages. Superusers only — this is the manual trigger behind the admin notifications page; scheduled sending will reuse the same pipeline. Use `dry_run` to validate without sending.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendCertificationNotificationRequest"}}}},"responses":{"200":{"description":"The digest was sent, validated (dry_run), or skipped as empty.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CertificationNotificationResult"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No recipient email address could be resolved for the user.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The digest could not be built or rendered.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"The mail server rejected the message or could not be reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/notifications/schedule":{"get":{"tags":["notifications"],"summary":"Read the notification schedule","description":"When the scheduled certification requirement digests go out — every day or one weekday, at an hour of a timezone — with the next occurrence, the last one claimed, and the latest runs. Superusers only.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The current schedule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationSchedule"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The schedule could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["notifications"],"summary":"Change the notification schedule","description":"Sets any of: whether scheduled sending is on, the cadence (`daily` or `weekly`), the weekday, the hour and the IANA timezone. An omitted field is left as it is. The hourly cron tick reads the schedule from the database, so a change takes effect within the hour. Enabling a schedule whose latest occurrence has passed does not send retroactively: the first run is the next occurrence. Superusers only.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateNotificationScheduleRequest"}}}},"responses":{"200":{"description":"The updated schedule.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationSchedule"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The schedule could not be written to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/notifications/schedule/tick":{"get":{"tags":["notifications"],"summary":"Tick the notification schedule (cron)","description":"Called hourly by the deployment's cron (`vercel.json`). Resumes a run a previous tick left unfinished; otherwise, when the schedule is enabled and its latest occurrence has not been claimed yet, claims it and sends the digests to every trainee with a role and every manager, within one invocation's time budget. Authenticated by the deployment's `CRON_SECRET` as a bearer token, not by an OIDC access token; a deployment without the variable answers 401 to everyone.","security":[{"cronSecret":[]}],"responses":{"200":{"description":"What the tick did.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationTickResult"}}}},"401":{"description":"The cron secret is missing or wrong.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The tick failed part-way; the next tick resumes the run.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/notifications/schedule/run":{"post":{"tags":["notifications"],"summary":"Send the scheduled digests now","description":"Starts a batch outside the schedule — the same digests to the same population the cron tick would send, whether or not scheduled sending is on — and processes as much of it as one invocation's time budget allows; a larger population is finished by the hourly tick. An unfinished earlier run is resumed instead of starting another, and while another invocation is still working on one the answer is `busy` with nothing started. Superusers only.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The run, with how far it got.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotificationTickResult"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The run failed part-way; the next tick resumes it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/branding":{"get":{"tags":["branding"],"summary":"Read the deployment branding","description":"The long-form and short names, the logo and the support address this deployment shows in place of the Botree defaults. Null fields mean the default is in use.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The current branding.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentBranding"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The deployment branding could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["branding"],"summary":"Set the deployment's names and support address","description":"Sets the long-form name shown in the footer, page titles, notification emails and the dashboard top bar on wide screens, and the short name shown as the dashboard wordmark and the top bar title on narrow screens. Either falls back to the other when only one is set; send null or an empty string to clear one, and omit a field to leave it unchanged. The footer keeps a \"Powered by Botree LRS\" credit while a custom name or logo is set. The support address is the one the public /support pages tell people to write to for help, support@botreeinc.com until one is set. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDeploymentBrandingRequest"}}}},"responses":{"200":{"description":"The updated branding.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentBranding"}}}},"400":{"description":"The request body failed validation.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The deployment branding could not be written to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/branding/logo":{"get":{"tags":["branding"],"summary":"Serve the deployment logo","description":"The uploaded logo's bytes, with its content type. Public — the header and footer load it as an image, and the browser as the favicon — and 404 while the Botree artwork is in use. The response is cacheable for a year: the pages append a version query that changes with every branding write, so a replaced logo is fetched under a new URL. A `size` query fits a PNG, JPEG or WebP logo inside a square of that many pixels (snapped up to the nearest of 16, 32, 48, 64, 96, 128, 256, 384, 512 or 1024); an SVG or GIF is served as uploaded whatever the size.","security":[],"parameters":[{"schema":{"type":"string","description":"Cache-busting version, as the branding endpoints report it. Ignored here.","example":"1735689600000"},"required":false,"description":"Cache-busting version, as the branding endpoints report it. Ignored here.","name":"v","in":"query"},{"schema":{"type":"integer","exclusiveMinimum":0,"description":"The rendition to serve, in pixels: the image is fitted inside a square of this size (snapped up to the nearest bucket, and never enlarged). Omit for the upload at its full resolution.","example":96},"required":false,"description":"The rendition to serve, in pixels: the image is fitted inside a square of this size (snapped up to the nearest bucket, and never enlarged). Omit for the upload at its full resolution.","name":"size","in":"query"}],"responses":{"200":{"description":"The image, served with the upload's content type."},"400":{"description":"The size query is not a positive integer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"No logo has been uploaded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The deployment branding could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"The logo could not be read from storage.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"post":{"tags":["branding"],"summary":"Upload the deployment logo","description":"Replaces the logo shown in the header, footer and browser tab with the image in the file part: PNG, JPEG, WebP, GIF or SVG, at most 2 MB. Square artwork fits best. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/DeploymentBrandingLogoForm"}}}},"responses":{"200":{"description":"The updated branding, its logo URL pointing at the new image.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentBranding"}}}},"400":{"description":"The file part is missing, not an accepted image type, or too large.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The image could not be stored, or the write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"File storage is not configured on this deployment.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"delete":{"tags":["branding"],"summary":"Remove the deployment logo","description":"Restores the Botree artwork in the header, footer and browser tab. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The updated branding, with no logo.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeploymentBranding"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The deployment branding could not be written to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/feature-flags":{"get":{"tags":["feature-flags"],"summary":"List the feature flags","description":"Every optional feature of this deployment and its state. A `disabled` feature's endpoints answer 404 with the error `feature_disabled` whatever the caller may otherwise do, and its pages answer 404; a `hidden` one keeps working but is left out of the sidebar and the dashboards. The directory, users, learning records, subject matters, certifications and search are not optional and are not listed.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Every feature and its state.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeatureFlagList"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The feature flags could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"patch":{"tags":["feature-flags"],"summary":"Set feature flags","description":"Sets the state of each named feature, all at once; a feature left out keeps its state. Switching a feature off deletes nothing — its records, attempts and files stay where they are, and switching it back on brings them back. Takes effect on the next request. Requires a superuser's access token.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFeatureFlagsRequest"}}}},"responses":{"200":{"description":"Every feature and its state, after the change.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeatureFlagList"}}}},"400":{"description":"The request body failed validation: an unknown feature or state, or a feature named twice.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The access token's subject is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The feature flags could not be written to.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/search":{"get":{"tags":["search"],"summary":"Search everything the caller can see","description":"The unified search behind the /search page and the global search dialog: one query over people, organizations, quizzes, learning pathways, courses, job aids, records and the catalogues, grouped by kind, then the questions of the public help pages (a `Help & support` group, linking to each answer; keyword matches only). Every entity group is scoped by the same visibility rules the entity's own list page enforces, so the caller never sees anything they could not already find elsewhere. Each group carries at most five hits and says whether more matched, with the list page to continue on. On a deployment that has configured AI search the groups also carry, after the keyword matches, the entities whose indexed content is nearest in meaning — the text of attached PDFs, and described images and videos, included — badged `AI match`, under exactly the same visibility rules; `mode` narrows that to one half.","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":200,"description":"The words to search for; every word must appear in one of an entity's searched columns. Queries shorter than two characters return every group empty.","example":"welding"},"required":true,"description":"The words to search for; every word must appear in one of an entity's searched columns. Queries shorter than two characters return every group empty.","name":"q","in":"query"},{"schema":{"type":"string","enum":["keyword","semantic","combined"],"description":"`keyword` matches every word as a substring of the entities' searched columns. `semantic` embeds the query and returns the entities whose indexed content is nearest in meaning — attached PDFs, images and videos included. `combined` lists both: keyword matches first in each group, then the further AI matches, badged `AI match`. Omitted, the mode is `combined` on a deployment with AI search configured and `keyword` elsewhere; `semantic` and `combined` answer 503 where AI search is off.","example":"combined"},"required":false,"description":"`keyword` matches every word as a substring of the entities' searched columns. `semantic` embeds the query and returns the entities whose indexed content is nearest in meaning — attached PDFs, images and videos included. `combined` lists both: keyword matches first in each group, then the further AI matches, badged `AI match`. Omitted, the mode is `combined` on a deployment with AI search configured and `keyword` elsewhere; `semantic` and `combined` answer 503 where AI search is off.","name":"mode","in":"query"}],"responses":{"200":{"description":"The grouped hits, in a fixed group order.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResults"}}}},"400":{"description":"The query is missing or too long, or the mode is not one of the two.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The database could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"AI search is not configured on this deployment, or its embedding provider did not answer.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/ai-search":{"get":{"tags":["ai-search"],"summary":"Read the AI search configuration and index","description":"What the deployment's environment configures for AI (semantic) search — the embedding provider and model, the media describer, the file size cap; never a key — and the state of the index: how many chunks of each kind of content it holds under the current model, how many an earlier model left behind, and the recent indexing runs. Superusers only.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The configuration and the index.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AiSearchStatus"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The index could not be read.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/ai-search/index/tick":{"get":{"tags":["ai-search"],"summary":"Tick the AI search index (cron)","description":"Called hourly by the deployment's cron (`vercel.json`). Resumes a run a previous tick left unfinished; otherwise starts an incremental pass over the sources changed since the last completed run, within one invocation's time budget. Answers `disabled` while no embedding provider is configured. Authenticated by the deployment's `CRON_SECRET` as a bearer token, not by an OIDC access token.","security":[{"cronSecret":[]}],"responses":{"200":{"description":"What the tick did.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchIndexTickResult"}}}},"401":{"description":"The cron secret is missing or wrong.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The tick failed part-way; the next tick resumes the run.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/ai-search/index/run":{"post":{"tags":["ai-search"],"summary":"Index now, or rebuild the index","description":"Starts an indexing run outside the schedule and processes as much of it as one invocation's time budget allows; a larger index is finished by the hourly tick. `incremental` (the default) embeds the sources changed since the last completed run; `full` walks every source — what to do after switching embedding model, since it also drops the previous model's chunks. An unfinished earlier run is resumed instead of starting another, and while another invocation is still working on one the answer is `busy` with nothing started. Superusers only.","security":[{"bearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StartSearchIndexRunRequest"}}}},"responses":{"200":{"description":"The run, with how far it got.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchIndexTickResult"}}}},"400":{"description":"The mode is not `incremental` or `full`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"The access token is missing or invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"403":{"description":"The caller is not a superuser.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"The run failed part-way; the next tick resumes it.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}}},"webhooks":{}}