# 16. Отчёт этапа 6: проверка, hardening и передача **Дата:** 2026-10-27, обновлено 2026-10-28 после закрытия продуктовых рисков. **Ветка:** `feature/groups-courses`. **Статус:** этап 6 закрыт. Продуктовые риски (legacy-активы, срок жизни JWT, сессии, пагинация аудита и N+1 имён групп) закрыты; оставшиеся ограничения перечислены в разделе 4 и не блокируют передачу. ## 1. Итоговый объём этапов 0–6 | Этап | Что реализовано | Отчёт | |---|---|---| | 0 | Схема и контракты: identity `V4–V13` (`V10` — аудит, `V11` — refresh, `V12` — absolute, `V13` — keyset-индексы), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) | | 1 | Роли `ADMIN/TEACHER/STUDENT`, группы-когорты и парсер имени, статусы студентов и аккаунтов, `membership_history`, импорт v2 | [11](11-stage1-report.md) | | 2 | Курсы, `course_no` для браузерных URL, история преподавателей и групп, bootstrap курса C, привязка лабораторных к курсу, копирование лабораторных, админ-UI | [12](12-stage2-report.md) | | 3 | Аккаунты преподавателей, курсовая область видимости, внутренние API identity с `LMS_INTERNAL_TOKEN`, кабинет преподавателя, профиль студента курса и скачивание всех попыток | [13](13-stage3-report.md) | | 4 | Повышение (dry-run/apply/rollback, планировщик 31 августа МСК), академотпуск, отчисление, выпуск, архив/восстановление, усиленное удаление, блокировка входа | [14](14-stage4-report.md) | | 5 | Курсовые материалы `/learn/course/{course_no}/...`, публичные глобальные материалы `ALL`, скрытые материалы для преподавателя, перепроверка статуса web-сессии | [15](15-stage5-course-materials.md) | | 6 | Права преподавателя на запись (лабораторные, ведомость, группы, материалы), центральный аудит `identity.audit_events`, CI merge gate, hardening и deploy-runbook | этот отчёт | Ключевые инварианты, действующие после этапов 0–6: - все лабораторные принадлежат курсу; существующие лабораторные, главы и активные группы перенесены в курс C; - API работает с UUID курса, браузерные URL — с `course_no`; - преподаватель видит только активные назначенные курсы; студент — только курсы активной группы; админ — все курсы; - студент видит только опубликованные оценки и комментарии; - активы наследуют видимость статьи → раздела → главы: курсовые требуют доступа к курсу, глобальные — аудитории, а файлы без связи со статьёй по умолчанию недоступны (`404`) всем, кроме админа; админ перечисляет их через `GET /api/v1/content/legacy-assets`; - access-токен живёт 15 минут, refresh — 7 дней с ротацией и отзывом; web-сессия скользящая на 7 дней, но не дольше абсолютного потолка 30 дней, и сама обновляет access-токен; истёкшие refresh-токены очищаются ночью; - аудит продолжается keyset-курсором `(occurred_at, id)`, а детали всех лабораторных курса отдаются одним пакетным запросом без N+1; - преподаватель своего курса может писать: лабораторные/уровни/варианты, ведомость, привязка групп и авторские материалы курса; чужие курс, лаба, студент и группа отклоняются на сервисах; - вход закрыт для отчисленных, выпущенных и архивных студентов, а также для архивных аккаунтов; web дополнительно перепроверяет статус; - повышение и жизненный цикл пишут историю; применение повышения идемпотентно; - чувствительные действия пишутся в центральный `identity.audit_events`, админ смотрит журнал на `/admin/audit`; - CI на каждый push/MR прогоняет `test ktlintCheck detekt`. ## 2. Проверка ### 2.1. Сборка, тесты и линтеры Выполнено 2026-10-27: ```bash ./gradlew --no-daemon test ktlintCheck detekt ``` Результат: `BUILD SUCCESSFUL`, код возврата `0`, все модули зелёные. Для получения актуальных счётчиков тесты запущены принудительно: ```bash ./gradlew test --rerun-tasks ``` | Модуль | Тестов | Failures | Errors | Skipped | |---|---:|---:|---:|---:| | identity-service | 103 | 0 | 0 | 0 | | academic-service | 106 | 0 | 0 | 0 | | progress-service | 38 | 0 | 0 | 0 | | web | 112 | 0 | 0 | 0 | | gateway | 2 | 0 | 0 | 0 | | markdown | 5 | 0 | 0 | 0 | | **Итого на момент этапа 6** | **366** | **0** | **0** | **0** | После закрытия рисков актуальный LDD-baseline — **366 тестов, 0 failures** (см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация gateway дополнительно проверена конфигурацией и security-матрицей ниже. Тот же набор команд выполняет `.woodpecker/` (Gitea + Woodpecker 3.x); на каждый push/MR добавлен smoke-workflow, полный release обязателен для тегов. ### 2.2. Upgrade-тест миграций Проверка выполнена на одноразовом PostgreSQL 18 (Testcontainers/контейнер, не production-БД): 1. Развёрнута схема master: identity `V1–V3`, academic `V1–V5`, progress `V1–V9`, `V11`, `V12`. 2. Загружены синтетические данные: администратор и два студента, две группы (`ПИ-11БО`, `ПИ-21БО`), зачисления, семестр, лабораторная, глава/раздел/ статья/актив, оценка, комментарий и попытка сдачи. 3. Поверх master применены миграции ветки: - identity `V4–V13`; - academic `V6–V12`; - progress `V13`. 4. Выполнены postflight-запросы и повторное применение `V13`. Результат: **успешно, без ошибок**. - **identity:** группы разобраны в `stream/course/number/degree/enrollment_year` (`ПИ-11БО` → поток `ПИ`, курс 1, номер 1, `БО`, год набора 2026; `ПИ-21БО` → курс 2, год 2025); статус `ACTIVE`; созданы `promotion_runs`, `promotion_run_items`; зачисления перенесены в `membership_history` действием `ENROLL`; добавлен `accounts.status`; `V10` создаёт `identity.audit_events` без внешних ключей и индексы по `occurred_at`/`action`; `V11` создаёт `identity.refresh_tokens` (SHA-256-хеш, `expires_at`, `revoked_at`, `replaced_by`, индексы по аккаунту и сроку); `V12` добавляет `absolute_expires_at` с бэкфиллом и `NOT NULL`; `V13` создаёт композитные индексы `(occurred_at DESC, id DESC)` и `(action, occurred_at DESC, id DESC)` под keyset-пагинацию. - **academic:** добавлены `labs.course_id`, `chapters.course_id/audience`, индексы `labs_number_course_uidx`, `chapters_slug_global_uidx`, `chapters_slug_course_uidx`, `article_assets_article_idx`, `courses_title_lower_uidx`. `V12__asset_links_backfill.sql` восстанавливает связи `article_assets` из markdown-ссылок `/api/v1/content/assets/{name}`; файлы без ссылок остаются несвязанными. Курс C миграциями не создаётся — его создаёт bootstrap после входа админа, поэтому `labs_without_course` до bootstrap ожидаемо больше нуля. - **progress:** `grade_comments.author_account_id` и `lab_grades.updated_by_account_id` созданы как nullable `uuid`; индекс `lab_grades_lab_idx` существует и валиден; старый комментарий остался без автора, как и задумано. - **V13 идемпотентна:** повторное применение `V13` (дважды) завершилось сообщениями `already exists, skipping` и не изменило схему. Это же проверяет `ProfileAuthorsMigrationTest` на Testcontainers. - **progress V10 отсутствует:** в ветке нет `progress-service/.../V10__profile_authors.sql`; в истории прогресса применяются `V11`, `V12`, затем `V13`. У academic `V10` — это другой файл, `V10__course_title_unique.sql` (уникальный индекс названия курса). - Переход **forward-only**: после `V13` откат на сборку без неё не пройдёт валидацию Flyway; `out-of-order` включать нельзя. ### 2.3. Security-проверки Проверено автоматическими тестами (актуальный прогон — 366 тестов, 0 failures) и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта negative-тестами «свой/чужой курс». | Сценарий | Ожидание | Факт | |---|---|---| | Аноним → курс/материалы курса | нет доступа | `401` на `/api/v1/courses/{id}/content/tree`; `/api/v1/courses/by-no/{no}` — `401` | | Аноним → публичные материалы | только глобальные `ALL` | публичное дерево содержит только глобальные главы с `audience = ALL`; курсовая глава через `/api/v1/content/...` — `404` | | Аноним → актив глобальной `ALL`-статьи | публично | `200` | | Аноним → курсовой/`STUDENTS`/`TEACHERS` актив | `404` | `404`, существование файла не раскрывается | | Студент своего курса → актив опубликованной статьи | доступ | `200`; скрытый курсовой актив — `404` | | Студент/преподаватель чужого курса → курсовой актив | `403` | `403` | | Преподаватель своего курса → скрытый курсовой актив | доступ | `200`; админ видит любой актив | | Глобальный `STUDENTS`-актив → преподаватель | `403` | `403`; студент получает `200` | | Legacy-актив без связи со статьёй | `404` для анонима/студента/преподавателя, файл доступен только админу | `404` всем, кроме `ADMIN`; `GET /api/v1/content/legacy-assets` под `ROLE_ADMIN` перечисляет несвязанные файлы | | Access/refresh | короткий access + ротация refresh | access JWT 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh 7 дней (`LMS_REFRESH_TOKEN_TTL`), абсолютный потолок сессии 30 дней (`LMS_SESSION_ABSOLUTE_TIMEOUT`); `POST /api/v1/auth/refresh` ротирует пару, не продлевая `absolute_expires_at`; повторное использование старого токена отзывает все сессии аккаунта; истёкшие токены чистит ночная задача | | Logout / смена пароля | сессии отзываются | `POST /api/v1/auth/logout` отзывает refresh; смена пароля — `revokeAll` | | Архив/отчисление/закрытая группа → refresh | обновление запрещено | `ensureSessionAllowed` при ротации: `EXPELLED`, `GRADUATED`, `ARCHIVED`, архивный аккаунт и `GRADUATED`/`ARCHIVED` группа дают `401` | | Web-сессия | 7 дней sliding, без повторного входа, быстрый отзыв | cookie `LMS_SESSION`, `Max-Age` 7 дней (`LMS_SESSION_TIMEOUT`), `HttpOnly`/`SameSite=Lax`; access обновляется за 2 минуты до истечения, cookie перевыпускается на каждом ответе; статус перепроверяется не реже чем раз в 45 секунд, `active=false` или `401/403` инвалидируют сессию, недоступность identity не разлогинивает всех; выход отзывает refresh | | Админ → курс, профиль, скачивание | полный доступ | доступ к любому курсу и студенту, скачивание любой попытки курса | | Преподаватель своего курса → чтение | курс, группы, профиль, материалы | видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки; `GET /api/v1/courses/{id}/groups` отдаёт привязанные группы с именами одним вызовом (без N+1) | | Преподаватель своего курса → запись | лабы/уровни/варианты, ведомость, группы, материалы | оценки/комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), привязка групп, авторство материалов; проверки в progress `CourseScopeService` и academic course/content authoring | | Преподаватель чужого курса | `403` | `403` на чтение и запись: материалы, курс по `course_no`, состав группы, профиль, лабы, оценки, группы | | Аудит → админ | чтение журнала | `GET /api/v1/audit?limit=&action=&cursor=` (offset `page` — для совместимости) и `/admin/audit` доступны только `ADMIN`; в UI — keyset-переход «Показать ещё», битый курсор — `400` | | Аудит → запись из сервисов | только по токену | `POST /api/v1/internal/audit` с `X-Internal-Token`; без/неверный токен — `403` | | Студент → свой профиль | только своё и опубликованное | скрытые оценки/комментарии отсутствуют; чужой `studentId` — `403` | | Студент → чужой курс/группа | `403` | доступ только к курсам активной группы; архивный курс — `403` | | Чужая сдача / лаба другого курса | `403` | скачивание отклоняется; своя попытка курса скачивается ZIP | | Внутренние API identity | только по токену | неверный/пустой `X-Internal-Token` — `403`, сравнение constant-time; неизвестный id — `404` | | Gateway | внутренние пути не публикуются | маршрутов на `/api/v1/internal/**` нет; gateway требует JWT для всего, кроме `/actuator`, входа/setup/JWKS/refresh/logout и публичного `/api/v1/content/**` | | Gateway → заголовки личности | клиент не подменяет | `X-Account-Id/Login/Role` удаляются и проставляются только после проверки JWT | Остаточные ограничения перечислены в разделе 4: ручная инвентаризация legacy-активов на production, сдачи по выбранной лабораторной в ведомости, непроверенный первый CI-прогон и отсутствие визуальной регрессии. Продуктовые риски legacy-активов, срока access-токена, пагинации, N+1 деталей лабораторных, абсолютного лимита сессии и ведущих нулей закрыты или сняты как не относящиеся к требованиям. ## 3. Чек-лист деплоя ### 3.1. Перед деплоем - [ ] **Backup обязателен.** Выполнить `./deploy/backup.sh backup` и `./deploy/backup.sh verify `. `./deploy/deploy.sh up` сам вызывает `backup.sh backup --if-running`; при ошибке backup деплой прерывается. Backup на том же сервере не считается единственной защитой. - [ ] В `.env.prod` задан **`LMS_INTERNAL_TOKEN`** — длинная случайная строка (`openssl rand -hex 32`), одинаковая для identity, academic и progress. Значение по умолчанию `dev-internal-token` в production не использовать. Там же задан `LMS_DB_PASSWORD`; `.env.prod` не коммитится. - [ ] Проверить, что на сервере нет незакоммиченных изменений и выполнен `git pull` нужной ветки/тега. - [ ] **CI зелёный.** `.woodpecker/` (Gitea + Woodpecker 3.x) на каждом push/MR выполняет workflow `check` (`test ktlintCheck detekt evidenceCheck`) и `smoke` (`scripts/ldd-smoke.sh`: полный стек, health, вход админа, один курс). Полный `release` обязателен для тегов и запускается вручную для веток. Агент — Docker backend на отдельной VM, шаги в nested dind; Java 21, Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit; установка — [`deploy/WOODPECKER.md`](../deploy/WOODPECKER.md). - [ ] Заданы сроки сессии: `LMS_ACCESS_TOKEN_TTL=15m`, `LMS_REFRESH_TOKEN_TTL=7d`, `LMS_SESSION_TIMEOUT=7d`, `LMS_SESSION_ABSOLUTE_TIMEOUT=30d`, `LMS_REFRESH_TOKEN_RETENTION=30d` (значения по умолчанию подходят; менять только осознанно). Очистка refresh запускается по `LMS_REFRESH_TOKEN_CLEANUP_CRON` (по умолчанию 03:30). ### 3.2. Порядок выкладки 1. **Flyway.** `./deploy/deploy.sh up` собирает образы и запускает `postgres → identity → academic → progress → gateway → web`. Миграции применяются автоматически при старте сервисов: identity `V4–V13` (`V10` — `identity.audit_events`, `V11` — `identity.refresh_tokens`, `V12` — `absolute_expires_at`, `V13` — keyset-индексы аудита), academic `V6–V12`, progress `V13`. Отдельный ручной запуск Flyway не нужен. 2. **Bootstrap курса C.** После входа админа открыть `/admin/courses`. Пока курсов нет, показан блок первичной настройки. Bootstrap идемпотентно создаёт/находит курс «Промышленное программирование на языке C», привязывает активные группы, переносит лабораторные и главы и, если лабораторных без курса не осталось, выполняет `ALTER TABLE academic.labs ALTER COLUMN course_id SET NOT NULL`. Это **post-bootstrap шаг в рантайме, а не Flyway-миграция**. Повторный запуск безопасен. Если в БД уже есть курс C, bootstrap не требуется. 3. **Postflight.** Выполнить `psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql` и проверить: - `labs_without_course = 0`; - `chapters_without_course = 0` (если все текущие материалы курсовые); - ровно один курс C; - `comments_without_author` соответствует старым комментариям; - группы без полного разбора отсутствуют; - таблица `identity.audit_events` существует (`V10`); - таблица `identity.refresh_tokens` существует (`V11`); - у `refresh_tokens` есть `absolute_expires_at NOT NULL` (`V12`); - индекс `audit_events_occurred_id_idx` существует (`V13`). 4. **V10 → V13 — только dev/staging.** На production/master записи `progress V10__profile_authors.sql` нет, ничего удалять не нужно. Не путать с academic `V10__course_title_unique.sql` — он относится к другой схеме и должен применяться как обычно. Если БД когда-либо применяла старую feature-миграцию V10: - сделать backup; - проверить `deploy/preflight/progress-v10-v13.sql` (`columns_ok` и `index_ok` должны быть `true`); - удалить **только строку истории** V10 (`success=true`) через `flyway repair` или точечный `DELETE` из примера в скрипте; объекты схемы не удалять; - при `success=false` сначала убедиться, что частичные объекты откатились; - не переставлять `V11`/`V12` и не включать `out-of-order`. После `V13` переход forward-only. ### 3.3. Smoke после деплоя - [ ] `docker compose -f docker-compose.prod.yml --env-file .env.prod ps` — все контейнеры `running`; `curl -fsS http://127.0.0.1:8090/actuator/health` отдаёт `UP`. - [ ] **Админ:** вход, `/admin/courses` (курс C и счётчики), `/admin/groups`, `/admin/labs`, `/admin/teachers`, `/admin/promotion`. - [ ] **Преподаватель:** вход, `/teacher`, карточка своего курса (имена групп, привязка/отвязка группы по `available-groups`), ведомость `/admin/groups/{groupId}?courseId=...` без админских контролов, оценка и публикация, уровень/вариант и раздача, защита (код/reset), профиль студента, скачивание попытки и ZIP группы, авторство материалов `/teacher/courses/{id}/content`. - [ ] **Студент:** вход, дашборд, лабораторная, сдача/история, курсовые материалы; видны только опубликованные оценки и комментарии. - [ ] **Материалы курса:** админ и преподаватель видят скрытые статьи, студент — только опубликованные; чужой курс даёт `403`, аноним уходит на `/login`. - [ ] **Активы:** картинки курсовой статьи открываются у студента своего курса и преподавателя; чужой курс — `403`, скрытая картинка у студента — `404`, публичная `ALL` — без входа; файл без привязки к статье — `404` для всех, кроме админа. Админ открывает `/admin/legacy-assets` (или `GET /api/v1/content/legacy-assets`, или `scripts/legacy-assets.sh`) и при необходимости привязывает или удаляет файлы. - [ ] **Копирование лабораторной:** админ копирует лабораторную в другой курс; создаётся черновик с уровнями и вариантами; оценки, сдачи, защиты и назначения не копируются; занятый номер даёт `409`. - [ ] **Профиль:** преподаватель своего курса видит сводку, комментарии, все попытки и скачивает ZIP; чужой студент/курс — `403`. - [ ] **Повышение:** `/admin/promotion` строит dry-run за год, данные не меняются; применение — только после проверки плана; откат доступен для завершённого запуска. На production не применять план «на всякий случай». - [ ] **Аудит:** `/admin/audit` открывается у админа, показывает события (например, `PROFILE_VIEW`, `SUBMISSION_DOWNLOAD`, `GRADE_SET`, `CONTENT_*`, `TEACHER_*`) с фильтром по действию и временем МСК; «Показать ещё» переходит по keyset-курсору (`cursor`), «В начало» возвращает к первой странице; преподаватель/студент получают `403`. - [ ] **Сессия:** после входа cookie `LMS_SESSION` живёт 7 дней, но не дольше 30 дней с момента входа; при работе дольше 15 минут страница продолжает работать без повторного входа (access обновляется автоматически). Выход и смена пароля требуют повторного входа; вход отчисленного/архивного студента и refresh закрытой группы отклоняются. - [ ] Проверить логи `web`, `gateway`, `identity`, `academic`, `progress` на `ERROR`/`WARN` после smoke. ## 4. Известные пробелы и follow-up 1. **Старый формат импорта не поддерживается.** Импорт принимает только шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ограничение зафиксировано в [11](11-stage1-report.md). Номер группы — числовой, поэтому отдельного требования к ведущим нулям нет. 2. **Инвентаризация legacy-активов на реальном хранилище.** UI `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы; остаётся прогнать их на production/staging и привязать или удалить найденные файлы. Это гигиена хранилища, а не открытый риск доступа: обычные пользователи уже получают `404`. 3. **Ведомость запрашивает сдачи по выбранной лабораторной.** Детали всех лабораторных курса теперь пакетные (`GET /api/v1/labs/details`), но ведомость при открытии конкретной лабораторной делает отдельный запрос сдач. Это не N+1 по студентам и не блокер. 4. **Browser E2E без визуальной регрессии.** 6 сценариев × Chromium/Firefox/ WebKit = 18/18, но нет screenshot-diff и полного набора мобильных страниц. На Fedora WebKit запускается через Docker-образ Playwright, потому что системных Ubuntu-библиотек нет. 5. **Первый реальный CI-прогон не подтверждён.** Конфигурация `.woodpecker/`, smoke и `scripts/ci-preflight.sh` готовы, но Woodpecker-сервер и агент на сервере ещё нужно развернуть и проверить первый push-MR pipeline и tag-job. 6. **Внешняя интеграция «Контингент» исключена из плана.** Отдельного anti-corruption слоя, синхронизации и внешних идентификаторов не будет без нового ТЗ и доступа к API. 7. **Mutation testing не 100%.** Остаются эквивалентные мутанты и Kotlin/Spring-специфичные мутации; PIT harness — JUnit 4. 8. **Нагрузочный smoke — не SLA.** Одна машина, синтетические данные; 125 чтений и p95 < 1 с — ориентир, а не гарантия production. ## 5. Рекомендуемые следующие шаги Приоритет — сверху вниз: 1. **Прогнать инвентаризацию legacy-активов на production/staging** через `/admin/legacy-assets` или `scripts/legacy-assets.sh`, привязать нужные файлы к статьям, остальные удалить. 2. **Развернуть Woodpecker и проверить CI.** Поднять server и Docker-backend агент на отдельной VM по [`deploy/WOODPECKER.md`](../deploy/WOODPECKER.md), включить репозиторий, дождаться зелёных `check`/`smoke` на push-MR и `release` на теге. 3. **Дальнейшее расширение E2E** — визуальная регрессия и остальные мобильные страницы, если UI продолжит расти. 4. **Пакетные сдачи ведомости**, если появятся курсы, где открытие ведомости станет заметно медленным. ## 6. Итог Этап 6 закрыт: права преподавателя на запись, аудит, CI, security-матрица, upgrade-тест миграций, видимость активов и deploy-runbook проверены. Продуктовые риски закрыты: legacy-активы secure-by-default, access/refresh и web-сессия с отзывом и абсолютным потолком, keyset-пагинация аудита, пакетные детали лабораторных и bulk-выдача групп. Оставшиеся ограничения (ручная инвентаризация на production, сдачи по выбранной лабораторной, непроверенный первый CI-прогон) задокументированы в разделе 4 и не блокируют передачу. ## 7. LDD baseline После этапа 6 и закрытия рисков текущая функциональность проверена через LDD-контур: реестр `docs/evidence/claims.json` (**57 claims, 27 P0 / 30 P1**), gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-workflow, mutation testing и браузерный Playwright E2E. Результат: **57/57 claims proven, P0 gaps = 0, 366 тестов, 0 failures**; mutation strength `CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit); конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. Workflow `smoke` выполняется на каждом push/MR, полный `release` обязателен для тегов и доступен вручную для веток. E2E нашёл и закрыл реальный дефект Jackson 3/Kotlin defaults. Подробности и остаточные ограничения — [`docs/evidence/baseline-2026-10-27.md`](evidence/baseline-2026-10-27.md).