Compare commits

..
Author SHA1 Message Date
agent 871bde1abd Merge pull request 'fix: enumerate Windows ACL entries by SID' (#63) from fix/windows-acl-sid-enumeration into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-03 09:11:40 +00:00
codex 5c26bbe8b5 docs: record SID-native Windows ACL checks
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 12:05:19 +03:00
codex 3d7df25d00 fix Windows ACL checks to enumerate SIDs directly
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 11:52:10 +03:00
agent 4d589eee95 Merge pull request 'Harden external release preparation and runtime compatibility' (#62) from fix/cd-release-runtime-readiness into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 3s
2026-11-03 07:48:07 +00:00
codex 3c17e2eb3b Document external Windows release handoff
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:41:53 +03:00
codex 54e30d4228 Make Windows harness cleanup safe for Git objects
CI / tests-and-images (pull_request) Successful in 2m16s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:35:49 +03:00
codex 00b0af69c2 Fix Windows ZIP mode fixture encoding
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 10:24:45 +03:00
codex 81ab7aac39 Harden Windows release mode harness
CI / tests-and-images (pull_request) Successful in 2m11s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:19:45 +03:00
codex 8748760594 Align Windows release bundle mode validation
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:12:52 +03:00
codex 9d553744a1 Harden Windows release bundle trust boundary
CI / tests-and-images (pull_request) Successful in 2m13s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 10:02:27 +03:00
codex 710c483f34 Harden external release preparation workflow
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 09:38:37 +03:00
agent f19faf628e Merge pull request 'Consolidate product, UI, and deployment plans' (#60) from feature/documentation-consolidation into main
CI / tests-and-images (push) Successful in 2m12s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-03 06:08:33 +00:00
codex 9fc269283b docs: align documentation footer versions
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-11-03 09:02:33 +03:00
codex 826c1d8ca5 Merge current main into documentation consolidation
CI / tests-and-images (pull_request) Successful in 2m47s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 08:54:25 +03:00
agent 9a98fafefb Merge pull request 'Improve search ranking and support empty reviews' (#61) from fix/search-relevance-and-empty-reviews into main
CI / tests-and-images (push) Successful in 2m14s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-02 23:44:12 +00:00
codex 849c19e2ab docs: record final search benchmark findings
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 02:38:36 +03:00
codex 1c7ddf2fcb Use DFS scoring for explicit multi-index search
CI / tests-and-images (pull_request) Successful in 2m14s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-03 02:20:23 +03:00
codex e653f3b50e Preserve multi-index scope in review snapshots
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 01:41:58 +03:00
codex 726b1ee745 Harden empty review validation and lease ranking
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 01:09:01 +03:00
codex aa6fabe970 Fix search lease terminology and empty reviews
CI / tests-and-images (pull_request) Successful in 2m19s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:47:33 +03:00
codex 7437aef346 Record documentation gates in changelog
CI / tests-and-images (pull_request) Successful in 2m17s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:22:21 +03:00
codex 849c7537ed Document atomic corpus publication gate
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:18:06 +03:00
codex ee03dc6cc7 Clarify data pipeline versions and corpus handoff gate
CI / tests-and-images (pull_request) Successful in 2m12s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-03 00:11:52 +03:00
codex 4cf96e3fa9 Address documentation roadmap review findings
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:58:47 +03:00
codex ba9564fca2 Narrow scratch ignores and clarify shared mockup
CI / tests-and-images (pull_request) Successful in 2m31s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:44:57 +03:00
codex 3eba203009 Complete transferred UI and operational criteria
CI / tests-and-images (pull_request) Successful in 2m22s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-02 23:43:11 +03:00
codex 3fd5695bb2 Clarify roadmap source history
CI / tests-and-images (pull_request) Successful in 2m23s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-11-02 23:41:46 +03:00
codex 42b3aa09a7 Consolidate product and deployment roadmap docs
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 3s
2026-11-02 23:40:45 +03:00
agent 7ece39f212 Merge pull request 'План развития платформы: сравнение с Токтом Онлайн' (#55) from feature/toktom-platform-development-plan into main
CI / tests-and-images (push) Successful in 3m4s
Runner smoke test / runner-smoke (push) Successful in 2s
2026-11-02 19:21:24 +00:00
42 changed files with 3589 additions and 970 deletions

No files matched your search

+5
View File
@@ -1,5 +1,6 @@
# Local credentials and secrets
credentials.json
codex.json
# Python/runtime artifacts (for the upcoming bot implementation)
__pycache__/
@@ -17,3 +18,7 @@ data/
# Local runtime logs
logs/
# Local generated reports; deliberately publish reviewed/sanitized evidence with git add -f
/reports/
/tmp/
+30
View File
@@ -2,6 +2,36 @@
## Не выпущено
- Deployment: подготовлен внешний handoff для Windows-клиента от точного Gitea
commit. Bundle фиксирует commit/tree/blob identity и SHA-256; installer
проверяет bundle и ACL до записи и не запускает Git. Bootstrap на release A
может подготовить release B только при точном совпадении pinned runtime-файлов
и release policy. Первый Windows task остаётся preparation-only; отдельные
auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для
bundle-проверки завершился успешно на точном PR SHA; реальная установка,
регистрация задач, SSH и production не выполнялись и не активировались.
- Infrastructure: Windows release installer и external receiver читают владельцев
и ACL ACE напрямую как SID, без преобразования в имена учётных записей. Это
устраняет зависимость проверки от разрешения имён; неизвестные SID остаются
видимыми и отклоняются существующими fail-closed guard-политиками. Проверены
synthetic unmapped-SID fixtures и read-only ACL preflight; production ACL не
менялись.
- Документация: объединены продуктовый, UI- и deployment-планы; уточнены
внешняя backup-retention политика и обязательный owner-approved maintenance
window для cold backup. Передача корпуса требует атомарной публикации
проверенного staging в `DATA_ROOT`; безопасное возобновление либо fail-closed
при прерывании остаётся release gate. Механизм атомарной публикации ещё не
реализован.
- Backend `0.13.4` / Frontend `0.3.4`, алгоритм поиска `search-8`: для явно
заданного multi-index поиска включена глобальная статистика DFS; signed review
snapshots сохраняют полный index scope, а пустую выдачу можно сохранить с
обязательным общим комментарием и точной проверкой рангов. Узкое расширение
термина аренды сохранено без глобальных весов. На executable SHA
`1c7ddf2` выполнен полный isolated прогон 7 500 запросов: изменились 537
порядков относительно исходного baseline, 6 выдач остались пустыми; это
наблюдения ранжирования, не экспертная оценка права. Три актуальных акта
проверялись только в отдельном локальном supplement; production и исходный
индекс не менялись.
- Успешный CI на `push` в `main` запускает release workflow: при настроенных
registry secrets он собирает образы rootless Kaniko, публикует digest
references и сохраняет schema-v4 descriptor с SHA-256 в Gitea artifact;
+16 -18
View File
@@ -66,10 +66,11 @@ spacing:
## Overview
**Статус: утверждено пользователем 10 октября 2026 года.** Это целевая
**Статус: утверждено пользователем 10 октября 2026 года.** Это нормативная
дизайн-система портала и внутренней лаборатории проверки поисковой выдачи.
Она ещё не внедрена в работающий интерфейс. Утверждение дизайна не означает
готовность публичного сервиса или разрешение на deployment.
Часть ресурсов и правил уже присутствует в исходном коде, но полное визуальное
соответствие и доступность не подтверждаются одним наличием кода. Утверждение
дизайна не означает готовность публичного сервиса или разрешение на deployment.
**Creative North Star: "Түндүк — спокойное пространство для работы с правом".**
@@ -80,20 +81,14 @@ spacing:
Это единственная нормативная спецификация дизайна проекта. Требования
исходного brief перенесены сюда; отдельный brief и ранние варианты удалены.
Визуальный ориентир — утверждённый пользователем
[концептуальный экран](docs/design/tunduk-desktop-concept.png).
Точные токены и правила этой спецификации важнее случайных деталей
генеративного изображения. Название продукта — **Акылдаш**; «УКУК» из
ранних концепций не используется.
Название продукта — **Акылдаш**; «УКУК» из ранних концепций не используется.
Изображение определяет целевую композицию, пропорции панелей и расположение
элементов поискового экрана. Эта спецификация определяет семантические токены,
шрифты, доступность и правила поведения. Расхождения между ними фиксируются
до реализации; утверждённые токены не заменяются цветами, взятыми пипеткой
из растрового изображения. Изменения постоянных правил вносятся сюда явно.
Этапы работ, исходные измерения и критерии визуальной приёмки находятся в
[едином плане корректировки UI/UX](docs/product/interface-correction-plan.md).
План не является второй дизайн-системой и не означает завершение реализации.
Целевые редактируемые состояния, композиция, измерения и критерии приёмки
собраны в [плане реализации и приёмки](docs/product/implementation-roadmap.md).
Макеты задают визуальное направление, но не источник юридических данных и не
доказательство работающих функций. Утверждённые токены не заменяются цветами,
взятыми пипеткой из растрового изображения. Изменения постоянных правил
вносятся сюда явно.
**Key Characteristics:**
@@ -141,8 +136,11 @@ spacing:
**Заголовки документа и разделов:** Noto Serif, веса 600 и 700.
**Интерфейс и основной текст:** Noto Sans, веса 400, 500 и 600.
Шрифты подключаются штатным способом, предпочтительно локальными WOFF2
с `font-display: swap`. До поставки проверить лицензии и символы
`Ө ө Ү ү Ң ң`. Файлы шрифтов пока не добавлены.
с `font-display: swap`. В исходном коде на сверенном `main` есть локальные
Noto Sans/Serif WOFF2 и OFL-лицензии в `backend/search/static/fonts/`;
`review.html` объявляет `font-display: swap`. Это подтверждает наличие ресурсов
и CSS, но не их загрузку на production и не полный визуальный аудит символов
`Ө ө Ү ү Ң ң`. См. [план реализации и приёмки](docs/product/implementation-roadmap.md).
| Элемент | Размер | Межстрочный интервал |
|---|---|---|
+19 -10
View File
@@ -9,20 +9,29 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
`0.13.2` и frontend `0.3.3` с поисковым экраном, внутренней лабораторией и
согласованным отображением доступных действий учетной записи.
В исходном коде находятся Telegram-бот-секретарь версии `0.2.2`, Search API
версии `0.13.4` (алгоритм поиска `search-8`) и frontend `0.3.4` с поисковым
экраном, внутренней лабораторией и согласованным отображением доступных
действий учетной записи.
Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль.
Поиск расширяет аббревиатуры организационно-правовых форм, применяет узкий
юридический вариант для общего словосочетания «договор аренды» и поднимает
действующий Налоговый кодекс для запросов о налоге на прибыль. Ограничения
текущего индекса и подтверждённые тестовые ошибки описаны в
[отчёте о релевантности](docs/product/search-relevance-findings-2026-11-03.md).
Версии описывают код, а не установленный production-релиз; актуальное
подтверждённое состояние среды см. в
[`docs/operations/CURRENT_STATE.md`](docs/operations/CURRENT_STATE.md).
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.13.2` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
| Frontend | `0.3.3` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| Telegram-бот | `0.2.2` | исходный код; последняя заметка о Synology указывает `0.2.1`, актуальность не перепроверена |
| Search API | `0.13.4` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
| Frontend | `0.3.4` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
| Загрузчик архива Минюста (`backend/ingestion/minjust_cbd.py`) | `0.9.3` (`APP_VERSION`) | версия CLI и User-Agent в исходном коде |
| Нормализатор корпуса (`backend/normalization/minjust_cbd.py`) | CLI `0.10.0` (`APP_VERSION`); формат/алгоритм `1.1.0` (`NORMALIZER_VERSION`) | версия инструмента и версия нормализатора в manifest — разные значения |
| Оценка релевантности | формат набора `relevance-set-v1` | отдельная версия компонента не задана; CLI использует Search API `0.13.4` |
| RAG и база знаний | — | ещё не созданы |
## Структура репозитория
@@ -64,4 +73,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.2 · Frontend v0.3.3
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
+12 -3
View File
@@ -1,11 +1,16 @@
# Backend Акылдаш
Версия: `0.13.0`
Версия загрузчика `minjust_cbd.py`: `0.9.3`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
`ingestion/minjust_cbd.py`: его можно запускать как CLI сейчас и импортировать
из будущего планировщика backend без запуска отдельного процесса.
Для передачи корпуса в production обязательна отдельная процедура
[CORPUS_HANDOFF.md](../docs/operations/CORPUS_HANDOFF.md); она проверяет
source/staging, inventory и checksum, решение по code 6, публикацию и
готовность индекса. Этот runbook — release gate; команды передачи здесь не
дублируются.
## Пробная загрузка
@@ -274,6 +279,9 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
Менять веса или анализаторы следует только после фиксации этого baseline и
разбора ошибок выдачи.
Диагностический отчёт с измерениями, подтверждёнными ошибками и ограничениями
текущего корпуса: [search relevance findings](../docs/product/search-relevance-findings-2026-11-03.md).
## Внутренняя лаборатория релевантности
Создайте первую учетную запись администратора:
@@ -292,7 +300,8 @@ PYTHONPATH=backend python3 -m search.api \\
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
комментариями. Поиск и API доступны только после входа. Пользователь `admin`
комментариями. Пустую выдачу можно сохранить как query-level оценку с
обязательным общим комментарием. Поиск и API доступны только после входа. Пользователь `admin`
управляет учетными записями; новые пользователи должны заменить временный
пароль при первом входе. Хранилище пользователей `data/search-users.sqlite3`
нужно резервировать отдельно от поискового индекса. Экспорт доступен
@@ -311,4 +320,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
---
Акылдаш · Backend v0.13.0 · Frontend v0.2.3
Акылдаш · Search API v0.13.4 · Frontend v0.3.4 · Telegram-бот v0.2.2
+26 -8
View File
@@ -21,7 +21,7 @@ from search.reviews import ReviewSnapshots, ReviewStore
API_VERSION = "v1"
SEARCH_ALGORITHM_VERSION = "search-6"
SEARCH_ALGORITHM_VERSION = "search-8"
LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
@@ -125,9 +125,12 @@ class Api:
def search_url(self, suffix: str) -> str:
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
def query_opensearch(self, body: dict) -> dict:
def query_opensearch(self, body: dict, *, search_type: str | None = None) -> dict:
try:
return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
url = self.search_url("_search")
if search_type is not None:
url = f"{url}?{urllib.parse.urlencode({'search_type': search_type})}"
return request_json(url, "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
except RuntimeError as error:
raise ApiError(502, "search backend is unavailable") from error
@@ -177,7 +180,8 @@ class Api:
if sort == "date":
body["sort"].append({"date_adopted": "desc"})
body["sort"].append({"_score": "desc"})
response = self.query_opensearch(body)
search_type = "dfs_query_then_fetch" if "," in self.index else None
response = self.query_opensearch(body, search_type=search_type)
try:
hits = response["hits"]["hits"]
except (KeyError, TypeError) as error:
@@ -185,7 +189,14 @@ class Api:
results = [self.search_hit(hit, language) for hit in hits[:page_size]]
snapshot_results = [{"rank": rank, **result} for rank, result in enumerate(results, 1)]
concrete_indexes = {hit.get("_index") for hit in hits[:page_size] if hit.get("_index")}
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
# A comma-separated index expression is the searched dataset scope;
# hits may come from only one of its members (or there may be no hits).
# For a single configured index/alias, keep the concrete-index provenance
# when OpenSearch returns exactly one concrete target.
if "," in self.index:
index_name = self.index
else:
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": results, "review_token": self.review_snapshots.create(text, language, index_name, snapshot_results, SEARCH_ALGORITHM_VERSION)}
@staticmethod
@@ -283,9 +294,18 @@ class Api:
raise ApiError(400, "overall_comment is too long")
if not isinstance(submitted, list):
raise ApiError(400, "results must be an array")
if len(submitted) > MAX_PAGE_SIZE:
raise ApiError(400, f"results must not exceed {MAX_PAGE_SIZE} items")
if any(not isinstance(item, dict) for item in submitted):
raise ApiError(400, "review result must be an object")
ranks = [item.get("rank") for item in submitted]
if any(type(rank) is not int for rank in ranks):
raise ApiError(400, "review result rank is invalid")
by_rank = {item["rank"]: item for item in snapshot["results"]}
if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank):
if len(ranks) != len(set(ranks)) or len(submitted) != len(by_rank) or set(ranks) != set(by_rank):
raise ApiError(400, "all search results must be reviewed exactly once")
if not snapshot["results"] and not overall_comment.strip():
raise ApiError(400, "overall_comment is required for an empty search result")
results = []
for item in submitted:
if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank:
@@ -300,8 +320,6 @@ class Api:
if not isinstance(comment, str) or len(comment) > 4000:
raise ApiError(400, "result comment is too long")
results.append({**source, "rating": rating, "comment": comment.strip()})
if not results:
raise ApiError(400, "at least one result must be reviewed")
review = {"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "reviewer": reviewer, "query": snapshot["query"], "language": snapshot["language"], "index_name": snapshot["index_name"], "algorithm_version": snapshot["algorithm_version"], "top_result_code": snapshot["results"][0]["code"] if snapshot["results"] else None, "results": results, "overall_comment": overall_comment}
review_id = self.review_store.save(review)
return {"api_version": API_VERSION, "id": review_id, "created_at": review["created_at"]}
+1 -1
View File
@@ -82,7 +82,7 @@ def main() -> int:
raise SystemExit("--top-k must be greater than zero")
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
print(json.dumps(result, ensure_ascii=False, indent=2))
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2", file=sys.stderr)
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4", file=sys.stderr)
return 0
+2 -2
View File
@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.13.3"
APP_VERSION = "0.13.4"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -552,7 +552,7 @@ def main() -> int:
else:
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
destination = arguments.output
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.3")
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
return 0
+30 -3
View File
@@ -22,6 +22,19 @@ LEGAL_FORM_ALIASES = {
"жоопкерчилиги чектелген коом": ("жчк",),
},
}
# The indexed Civil Code uses the legal term "имущественный наем" for a
# договор аренды. Keep this exact legal phrase expansion narrow: it must not
# rewrite residential hiring, employment, sublease, or leasing queries.
LEASE_TERM_ALIAS = re.compile(
r"(?<!\w)(договор(?:а|у|ом|е)?)[ \t]+аренд(?:а|ы|е|у|ой|ою)(?!\w)",
re.IGNORECASE,
)
LEASE_TERM_ALIAS_EXCLUSIONS = re.compile(
r"\b(?:квартир\w*|жил\w*|дом\w*|помещен\w*|земл\w*|участк\w*|"
r"поднайм\w*|подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b",
re.IGNORECASE,
)
LEASE_TERM_PHRASE_BOOST = 12
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
TAX_PROFIT_DOCUMENTS = (("112340", 3000),)
BOILERPLATE_PREFIXES = {
@@ -62,12 +75,22 @@ def relaxed_query_text(language: str, query: str) -> str:
return value
def lease_term_variant(query: str) -> str | None:
if LEASE_TERM_ALIAS.search(query) and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query):
return LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query)
return None
def query_variants(language: str, query: str) -> list[str]:
variants = [query]
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
if pattern.search(query):
variants.extend(pattern.sub(expansion, query) for expansion in expansions)
if language == "ru":
alias = lease_term_variant(query)
if alias:
variants.append(alias)
return list(dict.fromkeys(variants))
@@ -169,9 +192,13 @@ def build_search_query(language: str, query: str) -> dict:
"boost": 0.1,
}
})
lease_variant = lease_term_variant(query) if language == "ru" else None
phrase_matches = [
{"match_phrase": {f"text_{language}": {"query": variant, "boost": 3}}}
for variant in query_variants(language, query)
{"match_phrase": {f"text_{language}": {
"query": variant,
"boost": LEASE_TERM_PHRASE_BOOST if variant == lease_variant else 3,
}}}
for variant in variants
]
planned = {"should": [*full_text, *phrase_matches, *relaxed_text, *intent_clauses], "minimum_should_match": 1}
return {"bool": planned}
@@ -208,7 +235,7 @@ def main() -> int:
if arguments.top_k <= 0:
raise SystemExit("--top-k must be greater than zero")
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.2")
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.3.4")
return 0
+7 -7
View File
@@ -657,7 +657,7 @@
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs" hidden><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите документ в выдаче, чтобы открыть текст.</span><label class="search__document-language" hidden>Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
</div>
</main>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.3 <span aria-hidden="true">·</span> Backend v0.13.3</span></footer>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.4 <span aria-hidden="true">·</span> Backend v0.13.4</span></footer>
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
@@ -675,7 +675,7 @@
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.3 · Backend v0.13.3 · Внутренняя лаборатория релевантности</div></footer>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.4 · Backend v0.13.4 · Внутренняя лаборатория релевантности</div></footer>
<script>
document.body.classList.add('review__locked');
const DRAFT_KEY = 'akyldash-search-review-draft';
@@ -709,8 +709,8 @@
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
document.body.classList.toggle('is-review-route', reviewRoute);
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.3'; }
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.3'; }
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.4 · Backend v0.13.4'; }
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.4 · Backend v0.13.4'; }
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = roleLabel(user); $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.can_manage_users === true; $('review-nav').hidden = true; $('review-users-open').hidden = !canManageUsers || searchRoute; $('search-users-open').hidden = !canManageUsers || !searchRoute; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
@@ -1343,7 +1343,7 @@
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.3 · Backend v0.13.3`;
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.4 · Backend v0.13.4`;
if (usersRoute && account) loadUsers();
};
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
@@ -1500,9 +1500,9 @@
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); }
window.addEventListener('resize', () => { if ($('tutorial-dialog').open) renderTutorial(); });
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token && state.results.length ? 'Сохранить оценку' : 'Нет данных для сохранения'; $('save').disabled = !state.token || !state.results.length; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу сохранить нельзя.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); $('overall-comment').setAttribute('aria-required', String(!state.results.length)); $('overall-comment').placeholder = state.results.length ? 'Что важно учесть? (необязательно)' : 'Объясните, почему выдача пуста (обязательно)'; setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token ? (state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу') : 'Нет данных для сохранения'; $('save').disabled = !state.token; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу можно сохранить с комментарием.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); $('overall-comment').setAttribute('aria-required', 'false'); $('overall-comment').placeholder = 'Что важно учесть? (необязательно)'; setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } if (!state.results.length && !$('overall-comment').value.trim()) { setError('Добавьте комментарий к пустой выдаче.'); $('overall-comment').focus(); return; } const reviewer = $('reviewer').value.trim(); const results = state.results.map((result, index) => { const item = $('results').children[index]; return {rank: index + 1, code: result.code, rating: item?.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item?.querySelector('textarea')?.value || ''}; }); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; }
if (reviewRoute) $('tutorial-open').hidden = false;
</script>
+39
View File
@@ -3,6 +3,7 @@ import contextlib
import io
import tempfile
import unittest
import urllib.parse
from pathlib import Path
from unittest.mock import Mock, patch
@@ -190,6 +191,44 @@ class SearchApiTest(unittest.TestCase):
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
def test_global_dfs_is_only_sent_for_explicit_multi_index_searches(self):
with tempfile.TemporaryDirectory() as temporary:
multi = Api("http://opensearch:9200", "base-v2,supplement-v1", Path(temporary), review_secret=b"test-secret")
alias = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
single = Api("http://opensearch:9200", "single-v2", Path(temporary), review_secret=b"test-secret")
one_hit = {"hits": {"hits": [{"_index": "base-v2", "_source": {"document_code": "5", "document_name_ru": "Кодекс", "document_name_ky": "Кодекс"}}]}}
empty = {"hits": {"hits": []}}
alias_hit = {"hits": {"hits": [{"_index": "current-20261103", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}
aggregations = {"aggregations": {name: {"buckets": []} for name in ("document_types", "statuses", "authorities", "legal_forces")}}
with patch("search.api.request_json", side_effect=[one_hit, empty, alias_hit, one_hit, aggregations]) as request:
multi_ru = multi.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
multi_ky_empty = multi.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
alias_ru = alias.search({"q": ["test"], "language": ["ru"], "page_size": ["10"]})
single_ky = single.search({"q": ["test"], "language": ["ky"], "page_size": ["10"]})
alias.filters({"language": ["ru"]})
calls = request.call_args_list
multi_ru_url = urllib.parse.urlsplit(calls[0].args[0])
multi_ky_url = urllib.parse.urlsplit(calls[1].args[0])
self.assertEqual(multi_ru_url.path, "/base-v2%2Csupplement-v1/_search")
self.assertEqual(urllib.parse.parse_qs(multi_ru_url.query), {"search_type": ["dfs_query_then_fetch"]})
self.assertEqual(urllib.parse.parse_qs(multi_ky_url.query), {"search_type": ["dfs_query_then_fetch"]})
self.assertEqual([item["code"] for item in multi_ru["results"]], ["5"])
self.assertEqual(multi_ky_empty["results"], [])
self.assertEqual(multi.review_snapshots.verify(multi_ru["review_token"])["index_name"], "base-v2,supplement-v1")
self.assertEqual(multi.review_snapshots.verify(multi_ky_empty["review_token"])["index_name"], "base-v2,supplement-v1")
for call in calls[2:]:
parsed = urllib.parse.urlsplit(call.args[0])
self.assertEqual(parsed.query, "")
self.assertEqual(alias.review_snapshots.verify(alias_ru["review_token"])["index_name"], "current-20261103")
self.assertEqual(single.review_snapshots.verify(single_ky["review_token"])["index_name"], "base-v2")
multi_body = json.loads(calls[0].args[2])
alias_body = json.loads(calls[2].args[2])
self.assertEqual(multi_body, alias_body)
self.assertNotIn("search_type", multi_body)
self.assertEqual(json.loads(calls[1].args[2])["query"]["bool"]["filter"][0], {"term": {"language": "ky"}})
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -37,5 +37,29 @@ class SearchApiOpenSearchTest(unittest.TestCase):
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
class SearchRankingOpenSearchTest(unittest.TestCase):
def test_generic_lease_query_ranks_general_civil_code_first(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = os.getenv("AKYLDASH_OPENSEARCH_INDEX", "akyldash-fragments-v2")
api = Api(base_url, index, Path("."))
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
self.assertEqual(result["results"][0]["code"], "5")
@unittest.skipUnless(
os.getenv("AKYLDASH_OPENSEARCH_URL") and "," in os.getenv("AKYLDASH_OPENSEARCH_INDEX", ""),
"set AKYLDASH_OPENSEARCH_URL and a comma-separated AKYLDASH_OPENSEARCH_INDEX for read-only multi-index search",
)
class SearchMultiIndexDfsOpenSearchTest(unittest.TestCase):
def test_global_statistics_mode_accepts_read_only_multi_index_search(self):
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
index = os.environ["AKYLDASH_OPENSEARCH_INDEX"]
api = Api(base_url, index, Path("."))
result = api.search({"q": ["расторжение договора аренды"], "language": ["ru"], "page_size": ["10"]})
self.assertIsInstance(result["results"], list)
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], index)
if __name__ == "__main__":
unittest.main()
+31 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from unittest.mock import patch
from search.evaluate_relevance import evaluate, load_queries
from search.query import COMPANY_REGISTRATION_DOCUMENTS, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
from search.query import COMPANY_REGISTRATION_DOCUMENTS, LEASE_TERM_PHRASE_BOOST, TAX_PROFIT_DOCUMENTS, build_search_body, query_variants
class SearchRelevanceTest(unittest.TestCase):
@@ -49,6 +49,36 @@ class SearchRelevanceTest(unittest.TestCase):
self.assertIn("регистрация осоо", [variant.casefold() for variant in query_variants("ru", "регистрация общества с ограниченной ответственностью")])
self.assertIn("жчк ачуу тартиби", [variant.casefold() for variant in query_variants("ky", "жоопкерчилиги чектелген коом ачуу тартиби")])
def test_russian_lease_phrase_expands_only_the_exact_legal_construction(self):
original = "расторжение договора аренды"
variants = query_variants("ru", original)
self.assertEqual(variants[0], original)
self.assertIn("расторжение договора имущественного найма", variants)
body = json.loads(build_search_body("ru", original, 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
full_text = [item["multi_match"]["query"] for item in clauses if "multi_match" in item and item["multi_match"].get("operator") == "AND"]
self.assertEqual(full_text, variants)
phrase_matches = [item["match_phrase"]["text_ru"] for item in clauses if "match_phrase" in item]
self.assertEqual([item["boost"] for item in phrase_matches], [3, LEASE_TERM_PHRASE_BOOST])
for query in (
"аренда квартиры",
"расторжение договора аренды квартиры",
"договор аренды земельного участка",
"договор поднайма",
"договор аренды с правом поднайма",
"наем работника",
"договор лизинга",
):
self.assertEqual(query_variants("ru", query), [query])
self.assertEqual(query_variants("ky", "договор аренды"), ["договор аренды"])
self.assertEqual(
query_variants("ru", "договору аренды"),
["договору аренды", "договору имущественного найма"],
)
def test_relaxed_match_drops_ru_template_prefix_and_stays_low_weight(self):
body = json.loads(build_search_body("ru", "правовое регулирование расторжение договора аренды", 10))
clauses = body["query"]["bool"]["must"]["bool"]["should"]
+130 -2
View File
@@ -3,33 +3,140 @@ import unittest
from pathlib import Path
from unittest.mock import patch
from search.api import Api, ApiError
from search.api import Api, ApiError, SEARCH_ALGORITHM_VERSION
class SearchReviewTest(unittest.TestCase):
def test_multi_index_snapshot_and_saved_review_preserve_configured_scope(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "A,B", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
cases = (
("no hits", []),
("A only", [{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}}]),
("B only", [{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}}]),
("mixed", [
{"_index": "A", "_source": {"document_code": "7", "document_name_ru": "Закон A"}},
{"_index": "B", "_source": {"document_code": "8", "document_name_ru": "Закон B"}},
]),
)
for label, hits in cases:
with self.subTest(case=label):
with patch("search.api.request_json", return_value={"hits": {"hits": hits}}):
result = api.handle("GET", "/search?q=test&language=ru")[1]
token = result["review_token"]
self.assertEqual(api.review_snapshots.verify(token)["index_name"], "A,B")
review_results = [
{"rank": rank, "code": item["code"], "rating": 3}
for rank, item in enumerate(result["results"], 1)
]
api.handle("POST", "/search-reviews", {
"review_token": token,
"reviewer": "Юрист",
"results": review_results,
"overall_comment": "Проверена выдача для настроенного scope.",
})
saved = api.handle("GET", "/search-reviews/export")[1]["reviews"][-1]
self.assertEqual(saved["index_name"], "A,B")
def test_review_must_cover_each_snapshot_rank_once(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}, {"_index": "search-20260827", "_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}}]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
with self.assertRaisesRegex(ApiError, "exactly once"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": []})
with self.assertRaisesRegex(ApiError, "exactly once"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
def test_invalid_rank_types_and_sets_are_rejected_as_bad_requests(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), review_secret=b"test-secret")
response = {"hits": {"hits": [
{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}},
{"_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}},
]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
token = result["review_token"]
malformed = [
{"rank": True, "code": "7", "rating": 3},
{"rank": [1], "code": "7", "rating": 3},
{"rank": {"value": 1}, "code": "7", "rating": 3},
{"rank": None, "code": "7", "rating": 3},
{"rank": "1", "code": "7", "rating": 3},
{"rank": 1.0, "code": "7", "rating": 3},
None,
]
for item in malformed:
with self.subTest(item=item):
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": [item]})
self.assertEqual(raised.exception.status, 400)
for submitted in (
[],
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 1, "code": "7", "rating": 3}],
[{"rank": 1, "code": "7", "rating": 3}, {"rank": 3, "code": "8", "rating": 3}],
[{"rank": 1, "code": "7", "rating": 3}],
{"rank": 1, "code": "7", "rating": 3},
):
with self.subTest(submitted=submitted):
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": submitted})
self.assertEqual(raised.exception.status, 400)
oversized = [{"rank": 1, "code": "7", "rating": 3}] * 101
with self.assertRaises(ApiError) as raised:
api.handle("POST", "/search-reviews", {"review_token": token, "reviewer": "Юрист", "results": oversized})
self.assertEqual(raised.exception.status, 400)
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
with patch("search.api.request_json", return_value=response):
result = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru")[1]
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "search-20260827")
saved = api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3, "comment": "Прямой ответ"}]})
self.assertEqual(saved[0], 201)
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
self.assertEqual(exported[0]["index_name"], "search-20260827")
self.assertEqual(exported[0]["top_result_code"], "7")
self.assertEqual(exported[0]["results"][0]["rating"], 3)
with self.assertRaisesRegex(ApiError, "does not match"):
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "8", "rating": 3}]})
def test_saves_signed_empty_snapshot_with_query_level_comment(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
with patch("search.api.request_json", return_value={"hits": {"hits": []}}):
result = api.handle("GET", "/search?q=редкий+запрос&language=ru")[1]
self.assertEqual(api.review_snapshots.verify(result["review_token"])["index_name"], "current")
body = {
"review_token": result["review_token"],
"reviewer": "Юрист",
"overall_comment": "Пустая выдача подтверждена.",
"results": [],
}
with self.assertRaisesRegex(ApiError, "overall_comment is required"):
api.handle("POST", "/search-reviews", {**body, "overall_comment": ""})
saved = api.handle("POST", "/search-reviews", body)
self.assertEqual(saved[0], 201)
review = api.handle("GET", "/search-reviews/export")[1]["reviews"][0]
self.assertIsNone(review["top_result_code"])
self.assertEqual(review["results"], [])
self.assertEqual(review["overall_comment"], body["overall_comment"])
def test_review_page_enables_query_level_review_for_empty_results(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary))
page = api.review_page()
self.assertIn("state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу'", page)
self.assertIn("aria-required', String(!state.results.length)", page)
self.assertIn("const results = state.results.map((result, index) => { const item", page)
def test_snapshot_and_review_page_are_available(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary))
@@ -41,6 +148,27 @@ class SearchReviewTest(unittest.TestCase):
self.assertNotIn('id="error"', page)
self.assertIn("localStorage", page)
def test_new_search_snapshots_use_search_8_without_invalidating_search_7(self):
with tempfile.TemporaryDirectory() as temporary:
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
with patch("search.api.request_json", return_value={"hits": {"hits": [{"_index": "current-v2", "_source": {"document_code": "7", "document_name_ru": "Закон"}}]}}):
result = api.handle("GET", "/search?q=test&language=ru")[1]
current = api.review_snapshots.verify(result["review_token"])
self.assertEqual(SEARCH_ALGORITHM_VERSION, "search-8")
self.assertEqual(current["algorithm_version"], "search-8")
legacy_token = api.review_snapshots.create(
"legacy query", "ru", "current-v1", [{"rank": 1, "code": "7"}], "search-7"
)
legacy_saved = api.handle("POST", "/search-reviews", {
"review_token": legacy_token,
"reviewer": "Юрист",
"results": [{"rank": 1, "code": "7", "rating": 3}],
})
self.assertEqual(legacy_saved[0], 201)
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
self.assertEqual(exported[-1]["algorithm_version"], "search-7")
if __name__ == "__main__":
unittest.main()
+11 -1
View File
@@ -6,10 +6,15 @@
типографика, компоненты и правила адаптивности портала и лаборатории.
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
правила работы с данными.
- [Стратегическое сравнение с Токтом](product/platform-development-plan-toktom-2026-10-24.md) —
продуктовая гипотеза и конкурентный анализ; это не план исполнения задач.
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
MVP, зависимости и спринты.
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
обязательные работы и критерии перехода к frontend.
- [План реализации и приёмки](product/implementation-roadmap.md) — единая
очередность UI и deployment gates, со статусом доказательств; не дублирует
продуктовую стратегию или эксплуатационные процедуры.
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
@@ -30,6 +35,11 @@
состояние production и оставшиеся проверки.
- [Инфраструктура и эксплуатация](operations/INFRASTRUCTURE.md) — схема,
доступы, deployment, backup, rollback и recovery.
- [Операционные процедуры](operations/DEPLOYMENT.md) — связанные инструкции по
deployment, [backup](operations/BACKUP.md), [rollback](operations/ROLLBACK.md),
[recovery](operations/RECOVERY.md), обязательная процедура
[передачи корпуса](operations/CORPUS_HANDOFF.md) и
[пакет владельца](operations/DEPLOYMENT_OWNER_PACKET.md).
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
роли и этапы развития командного окружения.
@@ -45,4 +55,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.0 · Frontend v0.2.3
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
+27 -2
View File
@@ -168,8 +168,33 @@ backed up, resolve the raw code `6` finding, and approve the exact normalized
corpus to use. Transfer only `minjust-normalized/`; keep the raw source archive
offline. Do not preserve the source's Windows/WSL file permission bits. Copy
to a new staging location with owner-approved permissions, verify the copied
manifest and files there, and only then consider placing it under `DATA_ROOT`.
Never overwrite an existing corpus or OpenSearch volume.
manifest and files there, and only then consider publishing it under
`DATA_ROOT/minjust-normalized`.
### Atomic publication gate for `DATA_ROOT`
The verified staging tree must be published to
`DATA_ROOT/minjust-normalized` as one all-or-nothing operation, using an
owner-approved atomic mechanism or a separately documented equivalent. An
in-place copy into the live target is not an acceptable publication method.
Preserve the current published corpus until the candidate is fully verified and
ready for the atomic switch; never overwrite its contents in place.
The publication mechanism and its interrupted-operation recovery have not yet
been specified or tested; this is a release gate, not a claim that atomic corpus
publication is implemented. Until that gate is completed, do not publish or
replace a corpus in `DATA_ROOT`.
If preparation is interrupted before publication, keep the current target
untouched. Resume only from the isolated staging tree after a full rehash and
source comparison; if that cannot be established, fail closed and prepare a
new staging candidate. If interruption makes the outcome of the final
publication ambiguous, do not retry or replace anything automatically. Verify
the target against the approved manifest and resolve the state with the owner
before any retry. Never overwrite an existing corpus or OpenSearch volume.
The atomic filesystem publication of the approved corpus into `DATA_ROOT` and
the OpenSearch alias switch are separate gates. A successful corpus publish
does not authorize indexing or alias changes; do not conflate the two.
The bulk importer now refuses alias publication until all Bulk item results
are successful, the full staged index is refreshed, its fragment count exactly
+63 -21
View File
@@ -1,28 +1,70 @@
# Infrastructure current state
## Proposed external deployment runtime binding — 2026-11-01
## Windows ACL SID resolution — 2026-11-03
- PR #59 now proposes a root-only read-only status endpoint and exact-literal
SSH dispatcher for `status` and `deploy`. The status path bounds the Gitea
run list to ten entries, verifies the selected run/workflow/current `main`,
- Read-only PowerShell 5.1 inspection found that display-name conversion of
ACL entries can throw `IdentityNotMappedException` even though the ACL itself
is readable. The installer and external-receiver guards now enumerate access
rules directly as `SecurityIdentifier` values and read owners as SIDs; unknown
principals remain visible to the existing fail-closed policy. Regression
coverage exercises synthetic unmapped SIDs through the actual installer guard
functions. These repository changes are pending review; no Windows ACL,
installer, task, VPS, or production state was changed. The native PowerShell
harness still requires execution on Windows before this behavior is confirmed
in that runtime.
## External deployment runtime binding — 2026-11-03
- PR #59 is merged to `main`; it contains the root-only read-only status
endpoint and exact-literal SSH dispatcher for `status` and `deploy`. The
status path bounds the Gitea run list to ten entries, verifies the selected
run/workflow/current `main`,
validates artifact metadata and ZIP bytes, and returns only a release tuple.
It fails closed for a stale host checkout, recovery journal, incomplete
listing, or the exact release already recorded as applied. The deploy
receiver still independently revalidates and uses the existing lock,
recovery journal, cold-backup ACK, cutover, and rollback protections.
- The Windows client proposes a disabled-by-default `--once` mode with a
fixed `D:\Dev\akyldash` target and `akyldash_release` alias. The example
config and Task Scheduler XML are both disabled; the template uses an
interactive least-privilege owner, ignores overlapping invocations, and
does not wake the computer. No Windows task, SSH key, VPS command, helper,
or trigger config has been installed or exercised. Production CD remains
inactive.
- Remaining owner prerequisites are exact root forced-command/SSH host-key
setup for the dispatcher, installation of the reviewed Windows client under
a non-user-writable path, private ACLs for its config and D: backups, a
root-controlled checkout matching current `main`, and all previously listed
backup/restore and production readiness gates. The status endpoint does not
advance the host checkout and refuses to offer a stale release.
This unmerged change adds a two-SHA compatibility gate: the fixed root helper
compares protected Compose/Caddy bytes and installed release policy with
Gitea content pinned to the release SHA, verifying each Git blob hash and
size. This permits application-only release B after bootstrap A without
advancing the root checkout or executing new root code. A runtime config or
policy change fails closed and requires a reviewed bootstrap update. The
Windows response state is `prepared`, never `candidate`; the `--once` task
stores a protected local tuple and cannot call deploy. A separate disabled
auto-apply task and config gate require the tuple to match a fresh status
response from a previous poll. Status and apply retain current-main,
successful CI/release provenance, descriptor digest, external backup,
cutover, canary, and rollback checks. The receiver repeats the config check
before journaling/stopping services and again after backup immediately before
switching refs.
- Windows client предназначен для подготовки и auto-apply; обе задачи по умолчанию
выключены, целевой путь фиксирован: `D:\Dev\akyldash`, SSH alias —
`akyldash_release`. Примеры config и Task Scheduler XML также выключены; задачи
запускаются от интерактивного пользователя с минимальными правами, не допускают
параллельных запусков и не будят компьютер. Windows task, SSH key, VPS command,
helper и trigger не устанавливались и не запускались. Путь canonical
`C:\ProgramData\Akyldash\release-trigger.json` известен, но содержимое и
enabled-состояние не читались. Метаданные Windows подтверждают
`C:\Python313\python.exe`, действительную Authenticode-подпись Python Software
Foundation и защищённые ACL интерпретатора.
- Linux fixtures прошли для Python import и command-path poisoning, включая очистку
унаследованных `GIT_*` и фиксацию путей `ssh.exe`/`git.exe`/`powershell.exe`.
Installer получает ограниченный bundle из точной вершины `main`: упаковщик
читает Git objects commit, tree и allowlist-файлов; elevated installer проверяет
SHA-256 ZIP и цепочку `commit → tree → файлы` из одного read-locked потока до
записи и не запускает Git. ACL проверяются явными масками мутаций и подмены;
текущая учётная запись не может владеть кодовыми путями, а право создать
соседнюю папку не считается подменой предка. Windows ACL/install проверки пока
только прошли код-ревью; installer и launcher на Windows не запускались.
Production CD остаётся выключенным.
- Remaining owner prerequisites are installation of the reviewed root helper,
exact forced-command/SSH host-key setup, a protected Windows client and its
private state/config ACLs, private D: backup ACLs, and all previously listed
backup/restore and production readiness gates. Neither the status endpoint nor client advances
the host checkout or updates root helper code.
- Read-only Gitea check for fresh `main` SHA
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded; release
run #708 failed at the explicit registry credential-presence check because
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are absent; handoff run #709 was
skipped. No secret values were read. Image publication remains owner-blocked.
Owner decision recorded 2026-10-26: a complete backup must not be stored on
the VPS, including temporarily. The local archive/full cold-backup commands in
+113 -45
View File
@@ -2,10 +2,12 @@
## Status and authorization
The implementation in PR #59 is not merged, installed, or exercised against
the VPS. No release, backup, restore, or production service operation has been
performed by this change. Production deployment remains blocked until the
owner has verified host state, registry access, protected configuration,
The release and fixed-receiver code from PR #59 is on `main`, but has not been
installed or exercised against the VPS. This PR's compatibility and Windows
bootstrap changes are still proposed. No release, backup, restore, or
production service operation has been performed by this change. Production
deployment remains blocked until the owner has verified host state, registry
access, protected configuration,
corpus/index readiness, RU/KY canaries, an external backup, and restore on a
separate target. See [current state](CURRENT_STATE.md) and the
[owner evidence packet](DEPLOYMENT_OWNER_PACKET.md).
@@ -26,6 +28,13 @@ The descriptor records both the release workflow run ID and the successful CI
source run ID. PR builds do not publish images. Production runtime secrets are
not supplied to CI.
Read-only Gitea check on 2026-11-03 for current `main` SHA
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded, release
run #708 failed at the explicit registry-credential-presence check because
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are not configured, and handoff run
#709 was skipped. No secret values were read. Image publication therefore
remains blocked until the owner provisions the required registry secrets.
On the host, the fixed root receiver uses the installed `gitea-api` helper to
re-fetch and verify the exact successful CI and release runs, current `main`,
artifact name, checksum, manifest schema, builder pin, image policy, and commit
@@ -35,46 +44,103 @@ the response, and refuses to overwrite an existing file. The installer checks
the exact audited helper fixture hash before applying its patch. The helper
and source code are not installed on the VPS by this PR.
## One-shot runtime binding proposed by PR #59
## One-shot runtime binding and bootstrap compatibility
PR #59 adds a root-only, read-only status command and a fixed SSH dispatcher.
`main` includes a root-only, read-only status command and a fixed SSH dispatcher.
The dispatcher accepts only the literal status or deploy command in
`SSH_ORIGINAL_COMMAND`; it accepts no arguments and performs no shell
evaluation. Status fails closed on a stale root checkout, recovery journal,
incomplete Gitea run listing, failed source/workflow checks, invalid artifact
metadata/ZIP, or a release already applied. The candidate response contains
only a run ID, commit, and descriptor digest. The root receiver independently
repeats release verification and the existing backup/ACK/cutover guards.
evaluation. Status verifies successful current-main provenance, artifact
metadata/ZIP, and host runtime compatibility before returning only a run ID,
commit, and descriptor digest in a `prepared` response. It fails closed on a
recovery journal, incomplete Gitea run listing, invalid release data, changed
host runtime configuration, or an already applied release. The root receiver
independently repeats release verification and the existing backup/ACK/cutover
guards.
The Windows client adds a one-shot `receive_external.py --once` mode. Its
configuration is fixed to `D:\Dev\akyldash` and the `akyldash_release` SSH
alias, and is disabled by default. It uses BatchMode, disables forwarding,
uses the existing host-key checking, bounds status output and time, then calls
the existing `run_release` path. Rollback remains a separate explicit
owner-run command. The example Task Scheduler XML is disabled, runs as the
interactive owner at least privilege, ignores overlapping instances, and
never wakes the computer. No task has been registered or enabled by this PR.
The Windows `--once` mode only polls the fixed read-only status endpoint and
stores a protected local `PREPARED` tuple. It never sends a deploy request.
Automatic deployment uses a second task and a separate
`release-auto-apply.json` gate; both config files and both scheduler XML
templates default to disabled. The apply task acts only when its current
verified tuple exactly matches a tuple saved by a previous poll. An owner can
also use `--apply-prepared`, which requires an interactive exact-SHA
confirmation. Both deploy paths re-enter the existing root receiver and all
backup/ACK/cutover guards. Rollback remains a separate explicit owner-run
командой. Планировщик запускает задачи от интерактивного владельца с минимальными
правами, пропускает повторный запуск при активном экземпляре и не будит компьютер.
Этот PR не зарегистрировал и не включил ни одну задачу.
Before any activation, the owner must install the reviewed receiver and its
`archive.py` and `external_session.py` dependencies under a non-user-writable
`C:\Program Files\Akyldash` directory, set the config ACL so
only the owner, SYSTEM, and Administrators have full control, fill in the real
Python executable, owner SID, and local start time in the task template, and
confirm SSH host-key checking and the forced-command alias. Keep `enabled`
false and the task
disabled until the normal backup/restore and production readiness gates are
recorded. Do not bind a Gitea workflow or PR runner to this Windows host.
Сначала подготовьте bundle под обычной учётной записью из точной локальной
вершины `main` командой `prepare_windows_release_bundle.py`. Упаковщик читает
только Git objects указанного полного commit SHA, сверяет Git blob identity
исходников и создаёт небольшой ZIP с SHA-256; рабочее дерево он не читает и не
копирует. Elevated installer получает путь к ZIP, ожидаемый commit и SHA-256.
Он один раз открывает пакет с запретом записи/удаления, проверяет SHA-256 архива,
manifest и Git blob IDs в памяти, затем копирует только проверенные байты.
Дополнительно installer проверяет цепочку Git objects `commit → tree → файлы`.
На elevated этапе Git не запускается; `.git/config`, hooks и состояние checkout
не используются. Не изменяйте ZIP между подготовкой и установкой.
Упаковщик ставит всем ZIP entries тип regular file и mode `0100600`; installer
проверяет тот же mode (`0x8180`) до чтения содержимого. Для проверки стыка
упаковщика и фактической функции mode-валидации на Windows используйте
`powershell.exe -NoProfile -File infra/deploy/test_windows_release_bundle_mode.ps1 -PythonPath C:\Python313\python.exe`;
`git.exe` должен уже разрешаться обычным `PATH`. Harness создаёт синтетический
Git репозиторий и временный ZIP, извлекает только эту функцию из PowerShell AST и
проверяет принятие штатного пакета и отказ после изменения mode. Он не запускает
installer, не требует elevation, не меняет ACL и не регистрирует задачи; политику
выполнения PowerShell менять для него не следует.
The root-side status endpoint requires `/opt/akyldash/current` to exactly
match current `main`; it does not update that checkout. An owner must advance
the root-controlled checkout through the approved installation process before
a release built from a newer commit can be considered ready. Gitea's filtered
workflow-run listing is bounded to ten entries; if Gitea reports more entries
or an inconsistent page, status returns unavailable rather than guessing.
Проверенный bootstrap installer проверяет подпись PSF у
`C:\Python313\python.exe` и защищённые ACL, затем размещает файлы клиента в
SHA-версионном каталоге `Program Files`, не перезаписывая существующую версию.
Для новых файлов кода владельцем назначается `Administrators`. У кода владельцем
могут быть только SYSTEM, Administrators или TrustedInstaller; учётная запись
задачи допускается владельцем только для отдельного private state/config.
Проверка ACL файла и защищённого каталога запрещает недоверенным субъектам запись,
добавление, удаление, удаление дочерних объектов, смену прав или владельца. Для
родительских каталогов проверяются права, способные подменить защищённый путь;
разрешение создать посторонний соседний каталог подменой не считается.
Installer сохраняет существующий canonical-файл
`C:\ProgramData\Akyldash\release-trigger.json`, только если он выключен; второй
disabled auto-apply config создаётся только при отсутствии. Installer формирует
два disabled task XML для проверки владельцем, но не регистрирует и не включает
задачи. Launcher запускает Python с `-B -E -s -S`, оставляет в import path только
стандартную библиотеку и защищённый каталог клиента, затем проверяет происхождение
импортированных модулей. До импорта receiver он заменяет унаследованный command
lookup фиксированными каталогами Windows, OpenSSH и Git, очищает `GIT_*` и
проверяет, что `ssh.exe`, `git.exe` и `powershell.exe` разрешаются в заданные пути.
Для самих бинарников и их родительских каталогов проверяются reparse points,
владельцы и ACL по правилам выше. Тесты подмены Python imports и command paths
выполнены на Linux; installer и launcher на Windows не запускались. Оставляйте оба
config gate и обе задачи выключенными до фиксации владельцем обычных backup/restore
и production readiness gate. Не привязывайте Windows-хост к Gitea workflow или PR runner.
The root status/deploy helper remains the reviewed root-installed bootstrap
code; it never checks out or executes target release code. A newer app release
is eligible without manually advancing `/opt/akyldash/current` only when the
host's Compose file, Caddyfile, and installed release policy match the exact
bytes at the prepared commit in Gitea. File metadata is pinned to that commit;
the Git blob SHA and size are verified. Status checks before preparing the
tuple; the receiver repeats the check before creating a journal or stopping
services, and cutover checks again after the verified external backup and
immediately before switching image references. A Compose/Caddy mismatch
returns `runtime_config_changed`; a policy mismatch returns
`release_policy_changed` and requires a reviewed owner bootstrap update. No
host checkout or root helper is automatically changed. The release must still
be current `main`; CI/release provenance, digest, backup, canary, and rollback
checks remain required. Gitea's filtered workflow-run listing is bounded to
ten entries; if Gitea reports more entries or an inconsistent page, status
returns unavailable rather than guessing.
## Automation boundary: deployment is not active
The automatic chain currently ends at release publication:
Until the owner separately activates both Windows tasks and the auto-apply
configuration after the backup/restore and production gates, the chain ends at
release publication. After that explicit one-time owner authorization, the
prepared-state poll and separate apply task can process later matching
application releases without advancing `/opt/akyldash/current` or approving
each commit individually. A release that changes protected runtime files still
requires a reviewed bootstrap update.
1. Successful `main` CI triggers image builds and publishes digest references
plus the release descriptor artifact.
@@ -82,14 +148,15 @@ The automatic chain currently ends at release publication:
Its job is explicitly status-only: **NOT DEPLOYED**. It does not download
the descriptor ZIP, call the Windows client, open SSH, or invoke the root
receiver. A green result means only that this status check completed.
3. The fixed receiver is physically started only by an explicitly enabled
owner-controlled Windows trigger or by the manual release CLI. The proposed
`--once` path queries root's verified status endpoint and receives the
fixed tuple; no repository workflow invokes it. The example scheduler and
config remain disabled, and no Windows trigger has been installed or
exercised.
3. The fixed receiver is physically started only by the separately enabled
owner-controlled auto-apply task or by an interactive owner command. The
`--once` preparation task stores a verified tuple and never sends deploy;
the separate apply task requires the tuple to match a fresh status response.
No repository workflow invokes either task. Both scheduler templates and
both configuration gates remain disabled, and neither task has been
installed or exercised.
Production CD is still not active: no Windows task or credential is installed,
Production CD is still not active: task/credential setup has not been verified,
and no VPS or live D: operation has been performed. This design avoids a
Windows Actions runner entirely; the owner-controlled local task is not exposed
to PR jobs. The SSH account/key must be restricted to the exact dispatcher
@@ -150,9 +217,10 @@ from a root-owned checkout and provision, without exposing values in logs:
exact `SSH_ORIGINAL_COMMAND` matching and grant no shell, forwarding, PTY,
or arbitrary command access;
- a protected installation of the Windows client under
`C:\Program Files\Akyldash`, with the sample JSON and Task Scheduler XML
reviewed and kept disabled until the owner explicitly accepts unattended
polling.
`C:\Program Files\Akyldash`, with both config files and both Task Scheduler
XML templates reviewed. Keep the preparation task/config separate from the
auto-apply task/config; the auto-apply pair remains disabled until the owner
explicitly accepts unattended production deployment.
The installer intentionally does not create credentials, SSH entries, canary
values, registry permissions, or a new privilege rule. Existing files with
+119 -85
View File
@@ -1,91 +1,125 @@
# Deployment owner evidence packet
# Пакет проверок владельца перед деплоем
This packet is a list of owner-only checks. It does not authorize a production
change. Do not include token/password values, environment-file contents,
private keys, secret material, or raw process environments.
Здесь перечислены проверки, которые выполняет владелец. Этот документ не
разрешает production-изменения. Не включайте значения токенов и паролей,
содержимое env-файлов, приватные ключи, другие секреты или полный вывод
переменных окружения.
## Required before release use
## Что требуется до использования CD
- PR #59 proposes a disabled one-shot Windows trigger. No task or endpoint is
installed. If the owner chooses to activate it after merge and all other
gates, install the reviewed `receive_external.py`, `archive.py`, and
`external_session.py` client files in `C:\Program Files\Akyldash` with
write access limited to administrators/SYSTEM, copy
`infra/deploy/windows-release-trigger.example.json` to
`C:\ProgramData\Akyldash\release-trigger.json` and retain
`enabled:false`, then grant only the interactive owner, SYSTEM, and
Administrators full control on the config and its parent. Fill in the real
Python path, owner SID, and a future local start time in the XML template.
Review/import the task while
disabled; it runs as that logged-on owner at least privilege, ignores a
concurrent instance, does not wake the PC, and invokes only `--once`. Enable
the JSON and task only after the owner separately approves unattended
polling. Disable either one to stop future polls; an already running cold
backup/deploy must be allowed to finish or be explicitly recovered.
- The `akyldash_release` SSH key must be dedicated to the root forced-command
dispatcher, with OpenSSH `restrict` (or equivalent no-PTY/no-forwarding
options), verified host-key checking, and exact status/deploy command
matching. Do not put an unrestricted command or shell in root's
`authorized_keys`. The existing rollback key/path remains a separate manual
owner action. No key is generated, copied, or installed by this PR.
- Decide whether unattended production CD is acceptable. It currently is not
active: `deploy-handoff.yaml` remains status-only and reports `NOT DEPLOYED`;
the proposed local task and its config remain disabled. This avoids exposing
the Windows host to repository PR workflows. Verify that the root status
command is installed from a reviewed checkout, its forced-command key is
restricted, and the host checkout exactly matches current `main`. It does
not update that checkout automatically. The endpoint fails closed if the
filtered workflow-run API page is incomplete (more than ten candidates or
count mismatch), so status API behavior must be checked on the live Gitea
before activation.
- Confirm the registry package names and least-privilege writer/pull access for
`admin/akyldash/backend` and `admin/akyldash/opensearch`; record digest
retention for the rollback window.
- Confirm Gitea artifact retention covers the rollback window. The workflow
requests 90 days for the small release descriptor and checksum.
- Provide a dated, sanitized root audit: installed checkout SHA, Compose
services and image IDs, health state, volume paths/modes, protected-file
owner/mode, listener summary, and current recovery-journal state.
- Confirm a root-controlled SSH path exists for the fixed deploy and rollback
commands, using existing owner SSH configuration and verified host-key
checking. The repository `codex` account must not receive general sudo,
Docker-group membership, or a Docker socket. No SSH key or root alias is
provisioned by this PR.
- If required, install a narrowly scoped, read-only Gitea API token at
`/etc/akyldash/gitea-api-token` with root ownership and mode `0600`. Record
only whether it is present and its scope; never send its value.
- Verify the Gitea artifact download origin. If redirected, provision only the
exact HTTPS origin in `/etc/akyldash/gitea-download-origins` as a root-owned
mode-`0600` file. The patched helper must not send its token to that origin.
- Provide owner-approved RU and KY search canary queries in
`/etc/akyldash/release-canaries.json`, root-owned mode `0600`. Confirm they
return expected non-empty results without sending corpus documents.
- Confirm Cloudflare TLS mode, origin certificate renewal, and current public
`/review` and search health.
- Отключённый одноразовый Windows trigger из PR #59 уже находится в `main`, но
не установлен и не запускался. Bootstrap из этого PR также не установлен.
После ревью и слияния обновите локальный `main` до проверенного полного SHA.
Под обычным пользователем подготовьте bundle командой
`prepare_windows_release_bundle.py --repository D:\Dev\akyldash --commit <SHA> --output <new ZIP path>`.
Упаковщик требует, чтобы SHA совпадал с локальной вершиной `main`; он читает
Git objects этого commit, а не рабочее дерево, проверяет SHA-1 объектов
commit/tree/blob и печатает SHA-256 ZIP. Передайте именно эти SHA и путь
elevated installer:
`install_windows_release_client.ps1 -ExpectedCommit <SHA> -BundlePath <path> -ExpectedBundleSha256 <digest>`.
Installer открывает ZIP один раз с запретом записи и удаления, сверяет SHA-256,
manifest и цепочку `commit → tree → файлы` в памяти и только после полной
проверки копирует эти байты. Elevated installer не запускает Git и не читает
`.git/config`, hooks или файлы checkout. Не меняйте ZIP между подготовкой и
установкой; сохраните SHA и полный commit для журнала владельца.
- Installer проверяет подпись PSF у `C:\Python313\python.exe` и защищённые
ACL, создаёт версионный каталог в `Program Files` без перезаписи существующей
версии и назначает владельцем новых файлов кода `Administrators`. Кодовые
пути должны принадлежать только SYSTEM, Administrators или TrustedInstaller;
учётная запись задачи допускается владельцем только для отдельных private
state/config. ACL-проверка файла/кодового каталога выявляет запись, добавление,
удаление, удаление дочерних объектов, смену ACL и владельца. Проверка родительских
каталогов выявляет права, позволяющие заменить защищённый путь; создание
постороннего соседнего каталога само по себе не считается подменой.
- Installer сохраняет существующий `C:\ProgramData\Akyldash\release-trigger.json`
только если он остаётся отключённым, создаёт disabled auto-apply config лишь
при отсутствии и формирует два disabled Task Scheduler XML для проверки.
Он не регистрирует и не включает задачи. Путь trigger известен, но содержимое
и его enabled-состояние не читались.
- Launcher использует проверенный `C:\Python313\python.exe` с `-B -E -s -S`,
проверяет происхождение импортированных модулей, заменяет унаследованный `PATH`,
очищает `GIT_*` и фиксирует разрешение `ssh.exe`, `git.exe` и `powershell.exe`
в доверенные пути. Задача подготовки сохраняет защищённый `PREPARED` tuple и
никогда не отправляет deploy-запрос.
- Задача auto-apply и её отдельный конфиг тоже остаются выключенными. Одного
`enabled` в preparation config недостаточно для деплоя. Auto-apply разрешён,
только если свежий root status точно совпал с tuple, сохранённым предыдущим
опросом. После явного согласования владельцем unattended deployment отдельное
подтверждение каждого SHA не требуется. Включайте preparation config и задачу
только после отдельного разрешения на опрос; auto-apply config и задачу — только
после отдельного разрешения на unattended production deployment и выполнения
всех backup/restore gate. Отключение любого auto-apply control останавливает
будущие автодеплои; уже начатый cold backup/deploy должен завершиться либо быть
восстановлен по процедуре.
- Ключ `akyldash_release` должен быть отдельным и привязанным к root forced-command
dispatcher с OpenSSH `restrict` (или эквивалентным запретом PTY и forwarding),
проверкой host key и точным сопоставлением status/deploy-команд. Не добавляйте
unrestricted shell или команду в root `authorized_keys`. Существующий ключ и
путь rollback остаются отдельным ручным действием владельца. PR не создаёт,
не копирует и не устанавливает ключи.
- Решите, допустим ли unattended production CD. Сейчас он не включён:
`deploy-handoff.yaml` остаётся только status-проверкой и сообщает
`NOT DEPLOYED`; обе локальные задачи и оба config gate выключены. Windows-хост
не открыт workflow и PR runner. Убедитесь, что root status command установлен
из проверенного bootstrap и ключ forced command ограничен.
- Checkout на хосте не обязан совпадать с текущим `main`: status сравнивает
защищённые root Compose/Caddy файлы и установленную release policy с файлами
точного candidate SHA через pinned Gitea Contents API и проверяет Git blob IDs
и размеры. Receiver повторяет сравнение до остановки сервисов; cutover повторяет
его после backup. Несовпадение требует отдельного проверенного owner bootstrap
update; код release или root helper автоматически не выполняется и не обновляется.
Текущий `main` и CI/release provenance всё равно перепроверяются перед apply.
Endpoint закрывается при неполной странице workflow-run API (более десяти
кандидатов или несовпадение count). Перед включением проверьте поведение этого
API на установленной версии Gitea.
- Подтвердите имена registry packages и минимальные права записи/чтения для
`admin/akyldash/backend` и `admin/akyldash/opensearch`; сохраните digest не
короче rollback window. Последняя read-only проверка Gitea: main CI run #706
завершился успешно, image release run #708 завершился ошибкой из-за отсутствия
repository secrets `REGISTRY_USERNAME` и `REGISTRY_PASSWORD`, status-only
handoff run #709 был пропущен. Добавьте эти secrets через настройки Gitea;
не помещайте значения в Git или логи.
- Проверьте, что срок хранения Gitea artifacts покрывает rollback window.
Workflow запрашивает хранение небольшого release descriptor и checksum на 90 дней.
- Подготовьте датированный очищенный root audit: SHA установленного checkout,
Compose services и image IDs, состояние health, пути и режимы volumes,
владельца/режим защищённых файлов, сводку listeners и состояние recovery journal.
- Подтвердите root-controlled SSH путь для фиксированных deploy/rollback-команд,
используя существующую owner SSH-конфигурацию и проверенный host key. Репозиторный
`codex` не должен получать общий sudo, членство в Docker group или Docker socket.
PR не устанавливает SSH key или root alias.
- Если потребуется, установите узко ограниченный read-only Gitea API token в
`/etc/akyldash/gitea-api-token`, владелец root, mode `0600`. Запишите только
наличие и scope токена, не его значение.
- Проверьте origin скачивания Gitea artifacts. При redirect добавляйте только
точный HTTPS origin в `/etc/akyldash/gitea-download-origins`, root-owned,
mode `0600`. Исправленный helper не должен отправлять этому origin token.
- Добавьте одобренные владельцем RU и KY search canary queries в
`/etc/akyldash/release-canaries.json`, root-owned, mode `0600`. Убедитесь, что
они возвращают ожидаемые непустые результаты; не передавайте тексты корпуса.
- Проверьте TLS mode Cloudflare, продление origin certificate и текущую доступность
публичных `/review` и search.
## Backup and recovery gates
## Backup и восстановление
- The complete archive must remain on the owner-controlled Windows destination
`D:\Dev\akyldash\.backups`; never stage a full archive on the VPS.
- Synthetic Windows protocol checks and a disposable private-NTFS owner-ACL
fixture passed. Before relying on the flow, run its checks against the actual
D: path and local `.git/info/exclude`, then verify an approved backup is
published only after hash and manifest verification.
- Confirm D: encryption at rest. Archives contain production secrets and
private keys; the protocol encrypts transport but does not encrypt files at
rest.
- Restore a verified archive on an isolated separate target and check data,
SQLite integrity, OpenSearch, and required secrets. Do not use production as
a restore test.
- Confirm current data/index counts, corpus generation, and migration
compatibility. Image rollback does not restore data or undo schema changes.
- Set the archive retention period and keep the previous verified archive
throughout the rollback window.
- Полный архив хранится только на Windows-носителе владельца
`D:\Dev\akyldash\.backups`; не размещайте полный архив на VPS.
- Synthetic Windows protocol проверки и временная private-NTFS owner-ACL fixture
прошли. До использования проверьте реальный путь D: и локальное правило
`.git/info/exclude`; затем подтвердите, что одобренный backup публикуется только
после проверки hash и manifest.
- Подтвердите шифрование D: при хранении. Архив содержит production secrets и
private keys; протокол шифрует передачу, но не хранение файлов.
- Восстановите проверенный архив на отдельной изолированной цели и проверьте данные,
SQLite integrity, OpenSearch и необходимые secrets. Не проверяйте restore на production.
- Подтвердите текущие counts данных и индекса, corpus generation и совместимость
миграций. Откат образа не восстанавливает данные и не отменяет изменения схемы.
- Задайте срок хранения архивов и сохраняйте предыдущий проверенный архив весь
rollback window.
The installer does not create credentials, SSH configuration, canary values,
registry access, or new sudo rules. The PR has not been installed or run
against production. Windows validation passed 12 synthetic client checks, 8
protocol checks, and a disposable real-NTFS private owner-ACL fixture. The
actual D: path, SSH, full backup, and separate-target restore remain
unverified. Keep deployment disabled until the owner records evidence for
each gate above.
Installer не создаёт credentials, SSH configuration, canary values, доступ к
registry или новые sudo rules. PR не установлен и не запускался на production.
Windows validation прошла 12 synthetic client checks, 8 protocol checks и временную
private owner-ACL fixture на NTFS. Installer и launcher на Windows не запускались;
реальный путь D:, SSH, полный backup и restore на отдельную цель не проверены.
Не включайте deployment до фиксации владельцем результатов всех проверок выше.
-355
View File
@@ -1,355 +0,0 @@
# План точного воспроизведения поискового интерфейса
Дата: 23 октября 2026 года.
Статус: основная реализация и локальная приёмка выполнены в
`feature/frontend-pixel-perfect`; после первичного прогона скорректированы
навигация, плотность результатов, заголовок документа, fixture-контент,
клавиатурный trap для диалогов и компактные позиционируемые подсказки
лаборатории. Скриншоты, покропленные сравнения и расширенный
Playwright-сценарий находятся в репозитории; см. [отчёт](visual-review/README.md).
Локальные тесты и guards из CI прошли. Ожидают runner: проверки Compose/Caddy и
сборка production images. Независимый review запускает владелец. Скринридер и
реальный корпус всё ещё требуют проверки.
## 1. Цель, границы и источники истины
Привести существующую авторизованную поисковую страницу `/` к композиции
утверждённого изображения «Акылдаш · Түндүк», сохранив реальные API,
авторизацию, безопасность и отдельный сценарий лаборатории `/review`.
Источники:
- [DESIGN.md](../../DESIGN.md) — единственная нормативная дизайн-система:
токены, типографика, доступность, постоянные правила компонентов.
- [Исходный концепт](../design/tunduk-desktop-concept.png) — визуальный
эталон композиции. Вложение пользователя 1280×800 визуально соответствует
этому сохранённому изображению более высокого разрешения.
- [План frontend СПС](frontend-search-sps-plan.md) — продуктовый контекст.
- [Readiness plan](frontend-design-readiness-plan.md) — отдельные условия
готовности публичного продукта, которые этот план не отменяет.
Изображение задаёт композицию и пропорции; `DESIGN.md` задаёт токены,
шрифты и ограничения. До реализации фиксировать расхождения, а не молча
изменять палитру, шрифты или доступность ради совпадения растровых пикселей.
Временные задачи и результаты измерений остаются здесь; новые постоянные
правила добавляются в `DESIGN.md` без копирования всей дизайн-системы.
Не входят: публичный запуск, изменение прав доступа, ранжирования, корпуса,
индекса, production, регистрация, аналитика, сравнение редакций, переход
на новый frontend framework. Содержимое демонстрационного документа
не становится юридическими данными продукта.
Figma не является обязательной зависимостью: сохранённого эталона достаточно.
При необходимости совместного редактирования можно подготовить отдельный
Figma-макет с Auto Layout и переменными, ссылающийся на эти же правила;
это не заменяет проверку фактической вёрстки в браузере.
## 2. Исходное состояние до реализации
Таблица ниже фиксирует базовую проверку до реализации, выполненную по коду и
изображениям без браузера; строки не описывают текущее состояние. Последующие
результаты Playwright сохранены в [отчёте визуальной приёмки](visual-review/README.md).
Они относятся к локальному UI со стабильными fixture-ответами и не подтверждают
состояние production или актуального OpenSearch.
Основные файлы:
- `backend/search/review.html` — разметка, встроенные CSS и JavaScript обоих
сценариев, footer и авторизация.
- `backend/search/api.py` — выдача страницы для `/` и `/review`, API поиска,
фильтров и редакций.
- `backend/test_search_api.py` — существующие проверки API.
- `.gitea/workflows/ci.yaml` — обязательные проверки и сборка образов.
| Проблема | Подтверждение в реализации | Требуемый результат |
|---|---|---|
| Другая шапка | Общая `.review__header`, поиск отдельной формой ниже | Бренд, поиск и язык в одной верхней строке |
| Нет второй строки эталона | Нет общей полосы навигации и активных фильтров | Навигация и удаляемые фильтры под шапкой |
| Другие пропорции | `.search__layout`: `230px / .9fr / 1.2fr`, `min-height: 65vh` | Измеренная сетка и корректная высота рабочей области |
| Не тот вид фильтров | Все справочники выводятся radio-списками, даты вертикально | Компактные группы, select органа, даты рядом |
| Выбранный результат неполон | Есть только фоновое выделение | Фон, рамка, подпись «Открыт», точные отступы |
| Неполная панель документа | Есть заголовок, select редакции/языка, метаданные и текст | Полная иерархия и работающие действия эталона |
| Смешаны стили документа | Объявлен `.search__document-body`, статья использует `.review__document-body` | Одна корректная область стилей поискового документа |
| Шрифты не поставляются | Noto указаны в CSS, локальные файлы не найдены | Проверенная загрузка локальных шрифтов |
| Нет фирменных ресурсов | Знак сделан CSS-кружком; орнамент и favicon отсутствуют | SVG-знак, орнамент, favicon |
| Лабораторная панель на поиске | `.review__actions` вне скрываемого `<main>`; для `/` не скрывается | Оценивание только на `/review` |
| Счётчик вводит в заблуждение | Выводится `payload.results.length`; API не возвращает total | Явное число на странице либо отдельно реализованный total |
| Нет визуального контроля | Найденные тесты проверяют API, не геометрию страницы | Воспроизводимые браузерные снимки и сравнение |
Это исходное описание состояния до реализации. Сейчас переключатель RU/KY,
отдельный язык поиска, реальные редакции и язык документа, оглавление, поиск
по тексту документа, разворачивание, печать и отдельный маршрут `/review`
реализованы. Подробности текущей локальной приёмки и её ограничения приведены
в [отчёте](visual-review/README.md); оставшиеся непроверенные состояния не
считаются принятыми только потому, что чекбоксы плана перечислены ниже.
## 3. Этап 0 — воспроизводимый эталон
- [x] Перед реализацией перечитать актуальные инструкции, `DESIGN.md`,
реализацию маршрутов и обязательные проверки; использовать установленный
Ponytail согласно правилам репозитория.
- [x] Сохранить чужие незакоммиченные изменения, создать тематическую
`feature/` ветку и не переносить посторонние изменения в PR.
- [x] Зафиксировать основной viewport 1280×800 CSS px, браузер и его версию,
ОС, масштаб 100%, device scale factor и остальные viewport в отчёте.
- [ ] Получить исходный снимок текущего интерфейса на тех же размерах.
- [x] Подготовить детерминированные тестовые данные: запрос «налог на прибыль»,
три результата из изображения, выбран первый, открыта соответствующая
демонстрационная редакция, фильтр статуса и исходная прокрутка.
- [x] Изолировать fixture от реального режима и пометить демонстрационное
содержимое только в тестовом сценарии. Не добавлять подмену юридических данных
в production-путь.
- [x] Нормализовать сохранённое изображение до 1280×800 без изменения
пропорций и сохранить параметры подготовки эталона.
- [x] Записать конфликты изображения с нормативной типографикой и токенами;
согласованные ограничения приведены в отчёте.
Критерий завершения: один и тот же тестовый сценарий воспроизводится
независимо от состояния OpenSearch и текущей реальной выдачи; реальный режим
продолжает использовать API. Авторизация не ослабляется ради снимков.
## 4. Этап 1 — карта геометрии
Эталон 1586×992 пропорционально вписан в 1280×800; фактические значения
сверены с Playwright при device scale factor 1. Допуск границ крупных зон —
около 1 CSS px.
| Область | Нормализованный эталон | Реализация |
|---|---:|---:|
| Шапка и строка навигации | y ≈ 0–126 px | y = 0–127 px |
| Рабочая область | y ≈ 126–744 px | y = 127–744 px |
| Footer | y ≈ 744–800 px | y = 744–800 px |
| Фильтры | x ≈ 0–226 px | x = 0–226 px |
| Результаты | x ≈ 226–648 px | x = 226–648 px |
| Документ | x ≈ 648–1280 px | x = 648–1280 px |
Внутренние контрольные координаты и ограничения см. в
[отчёте визуальной приёмки](visual-review/README.md); там же есть overlay и
разница по каждой крупной области.
- [x] Измерить крупные границы, основную навигацию и ключевые элементы
документа по исходнику; фактические значения записаны в отчёте.
- [ ] Измерить все внутренние отступы, размеры полей и кнопок, межстрочные
интервалы и разделители по всем областям.
- [x] Зафиксировать адаптивные точки и проверить сетку на 1440/1024/390/320 px;
реальные длинные данные ещё не проверены.
- [ ] Разделить метрики повторяемых элементов и высоты, зависящие от текста.
Не выравнивать принудительно высоту всех результатов или разделов.
- [ ] Определить владельца прокрутки каждой области, поведение footer
при коротком содержимом и доступность длинного документа.
Критерий завершения: геометрия описана измерениями и адаптивными правилами,
а не независимыми приблизительными настройками каждого блока.
## 5. Этап 2 — разделение сценариев и базовых стилей
- [x] Разделить композиции поиска и лаборатории, сохранить маршруты,
авторизацию, кабинет, управление пользователями, обучение и черновики.
- [x] Ограничить лабораторные элементы маршрутом `/review`, включая
панель проверяющего и сохранения; маршрут проверен браузерным тестом.
- [ ] Использовать общий слой семантических токенов и базовых элементов;
стили поискового экрана изолировать BEM-классами.
- [ ] Устранить дублирующие и неиспользуемые правила, исправить класс статьи.
- [ ] Убрать зависимость шапки и footer поиска от лабораторного layout.
- [ ] Если CSS/JS/ресурсы выносятся из HTML, добавить ограниченную выдачу
static-файлов с MIME, защитой от обхода путей и сохранением защитных
заголовков. Убедиться, что ресурсы входят в production-образ.
Ожидаемые зоны изменений: `backend/search/review.html`, при необходимости
каталог static-ресурсов рядом с ним и `backend/search/api.py`.
Не вводить Next.js, React или новый сборщик только ради этой задачи.
Критерий завершения: оба сценария работают независимо, общие CSS не меняют
их композицию случайным образом; существующие права доступа сохранены.
## 6. Этап 3 — шрифты, знак, орнамент и favicon
- [ ] Поставить локальные WOFF2 Noto Sans 400/500/600 и Noto Serif 600/700
с лицензиями; исключить случайное синтетическое начертание.
- [x] Проверить загрузку, `font-display`, кириллицу и `Ө ө Ү ү Ң ң`.
Визуальные снимки выполнять после `document.fonts.ready`.
- [ ] Подготовить SVG-знак по визуальному направлению референса;
сверить размер, толщину линий и положение относительно названия.
- [ ] Подготовить отдельный симметричный золотистый SVG-орнамент,
`aria-hidden`, без перекрытия управления и текста.
- [x] Подготовить favicon SVG и растровый fallback 32×32; подключить
в `<head>`, проверить выдачу и MIME. Масштабирование SVG проверено визуально.
- [ ] Подготовить единые SVG-иконки поиска, очистки, календаря, печати,
оглавления, разворачивания и стрелок; иконкам-кнопкам дать доступные имена.
- [ ] Перенести значения в токены, не размножать HEX по компонентам.
Критерий завершения: шрифты реально загружены, знак и орнамент являются
ресурсами, favicon отображается; нет CSS-заглушек вместо фирменной графики.
## 7. Этап 4 — сборка композиции
Работать от крупных областей к деталям; сначала геометрия, затем типографика.
- [ ] Шапка: знак, название, разделитель, подпись продукта, поле с подписью
и очисткой, «Найти», язык интерфейса. Служебные действия доступны компактно.
- [ ] Вторая строка: «Поиск», «Об источниках», удаляемые фильтры;
демонстрационная подпись только для fixture. «Об источниках» открывает
реальные сведения о происхождении данных, а не пустую заглушку.
- [ ] Три панели: непрерывные границы, правильные фоны и пропорции,
отсутствие лишнего свободного ряда статуса в стабильном состоянии.
Сообщения для скринридера сохраняются.
- [ ] Фильтры: заголовок, текстовый сброс, статус, вид, орган, даты рядом,
орнамент в свободной нижней части без перекрытия длинного содержимого.
- [ ] Результаты: заголовок и честный счётчик, компактная сортировка,
строки выдачи, выбранная строка с рамкой/фоном/«Открыт», метаданные,
безопасная подсветка выдержки, пагинация.
- [ ] Документ: breadcrumbs и разворачивание, отдельная роль заголовка,
компактные метаданные, язык, действия, поиск внутри, поле чтения.
- [ ] Footer: бренд слева, фактические версии frontend/backend справа;
не копировать устаревшую backend-версию и слово «макет» из изображения.
Критерий завершения: порядок, вложенность и пропорции всех крупных зон
соответствуют эталону; нет лишних лабораторных блоков.
## 8. Этап 5 — рабочее поведение и интеграция
- [ ] Сохранить поиск, сортировку, пагинацию, загрузку редакций и языков.
- [x] Синхронизировать фильтры с URL и удалять отдельный фильтр без сброса
остальных; сохранение второго фильтра проверяется Playwright.
- [ ] Проверить восстановление и историю навигации через прямые ссылки и Back/Forward.
- [ ] Согласовать вид фильтров с контрактом: текущий API принимает одиночные
значения. Не имитировать множественный выбор checkbox без поддержки API.
Если нужен multiselect, отдельно описать и проверить расширение контракта.
- [ ] Разделить язык интерфейса, поиска и текста документа. Для RU/KY
перевести подписи, статусы и ошибки; смена UI не подменяет текст редакции.
- [ ] Не выдавать число на странице за общее: использовать явную подпись
либо отдельно реализовать достоверный total с проверкой его семантики.
- [ ] Построить оглавление по реальным заголовкам, с корректными якорями;
при отсутствии структуры показать понятное состояние.
- [ ] Реализовать поиск по текстовым узлам документа: ввод/очистка,
подсветка, активное совпадение, счётчик, предыдущий/следующий,
прокрутка и состояние нулевого результата. Не повреждать HTML,
ссылки и таблицы; не вставлять пользовательский запрос через `innerHTML`.
- [ ] Реализовать разворачивание/возврат с сохранением выбранной редакции,
прокрутки и корректного фокуса.
- [x] Добавить печать через print CSS: документ, его реквизиты и источник
без поисковых и служебных панелей; служебные элементы проверены в print media.
- [ ] Защитить смену запроса/документа/редакции от запоздавших ответов
через отмену запроса или проверку актуальности результата.
- [ ] Сохранить безопасный вывод очищенного документного HTML и ссылок,
обработку ошибок и ограничения авторизации.
Критерий завершения: каждый видимый элемент эталона выполняет действие;
нет декоративных кнопок, фиктивных языков и выдуманных юридических статусов.
## 9. Этап 6 — адаптивность, типографика и состояния
- [ ] Настроить размеры, веса, line-height и отступы после фиксации сетки.
Сверить переносы названия документа, результатов и плотность абзацев.
- [x] Проверить 1280×800 и ширины 1440/1024/390 px; дополнительно 320 px.
Высоты и параметры окружения записаны в отчёте.
- [ ] Проверить реальные длинные редакции, длинные названия на RU/KY и подобрать
breakpoints на реальных данных корпуса.
- [x] На узких экранах открывать фильтры отдельной доступной панелью,
документ — отдельным представлением с возвратом к результатам; проверено на 390 px.
- [x] Сокращать/убирать орнамент на телефоне; сохранять текст без
горизонтальной прокрутки, широкие таблицы — в собственной области прокрутки.
Проверено синтетической широкой таблицей; реальные таблицы корпуса остаются открыты.
- [x] Проверить длинные названия, отсутствие редакций, широкую таблицу и
широкое изображение синтетическими данными.
- [ ] Проверить одноязычные реальные редакции, ссылки, много фильтров и
длинный контент из реального корпуса.
- [x] Проверить загрузку редакции, empty, search/edition error, retry,
disabled-состояние без редакции и focus-visible; query сохраняется при ошибке.
- [ ] Проверить hover и сценарии ошибок авторизации/администрирования.
- [x] Проверить Tab/Shift+Tab/Escape, возврат фокуса мобильной панели и
диалогов, а также `prefers-reduced-motion`.
- [ ] Проверить доступные имена всех элементов, live-сообщения, контраст,
масштабирование, скринридер и touch-области на поддерживаемых устройствах.
Критерий завершения: точность desktop не приводит к переполнению,
недоступности действий или потере контекста на узком экране.
## 10. Этап 7 — визуальная приёмка
Выполнить один пакет первичных снимков всех контрольных размеров,
собрать дефекты по областям, исправить одним пакетом и выполнить один
подтверждающий проход. Новые циклы обосновывать конкретными оставшимися
дефектами или замечаниями независимого reviewer, не бесконечной полировкой.
- [x] Дождаться шрифтов и стабильных fixture-данных, задать исходную
прокрутку; снимки делаются после `document.fonts.ready`.
- [x] Проверить, что снимки показывают загруженный поисковый сценарий, а не
login, ошибку или другой маршрут.
- [x] Для 1280×800 подготовить overlay 50%, difference image и отдельные
сравнения шапки, фильтров, результатов, документа и footer.
- [x] Сохранить снимки остальных размеров и отчёт с условиями, отклонениями,
исправлениями и известными ограничениями.
- [ ] Передать независимому reviewer запрос пользователя, эталон,
`DESIGN.md`, этот план, diff и снимки всех проверенных размеров.
Критерии для детерминированного fixture при 1280×800:
| Проверка | Условие приёмки |
|---|---|
| Границы крупных зон | Отклонение от зафиксированной карты до 2 CSS px |
| Отступы и размеры управления | Отклонение до 2 CSS px от измерений |
| Ключевые заголовки | Совпадают переносы при согласованных шрифтах; исключения объяснены |
| Композиция | Все зоны и элементы эталона присутствуют, порядок совпадает |
| Ресурсы | Загружены шрифты, знак, орнамент, favicon, единые иконки |
| Рабочий сценарий | Поиск, фильтры, редакции, действия и навигация работают |
| Адаптивность | Нет перекрытий, обрезанного управления и общей горизонтальной прокрутки |
| Изоляция | Нет лабораторного оценивания на поиске, `/review` без регрессий |
Нулевая попиксельная разница не является критерием: сглаживание текста,
растровая природа концепта и нормативные токены дают объяснимые различия.
Один процент сходства всего экрана не заменяет проверки каждой области.
Достижение допусков подтверждается измерениями, а не общей оценкой «похоже».
## 11. Этап 8 — проверки, версия и PR
Этот этап относится к реализации в feature-ветке. Frontend повышен до `0.2.3`,
backend — до `0.13.0` для allowlist-выдачи локальных шрифтов. Контракт Search
API v1 не менялся.
- [x] Выполнить доступные локально проверки из `AGENTS.md` и CI: backend,
Telegram bot, backup, deploy, host/root/verifier/audit/Compose/production
config/runner guards, shell syntax и Playwright.
- [ ] Получить успешный CI на назначенном runner: `docker compose config`,
проверка Caddyfile и сборка backend/OpenSearch production images.
- [x] Добавить проверки фактических изменений: static/favicon, браузерные
сценарии поиска/редакций/поиска внутри/навигации и регрессии лаборатории;
визуальное сравнение использует стабильный fixture.
- [x] Повысить версии для совместимого функционального изменения;
frontend/backend версии согласованы в footer.
- [x] Выполнить push и найти существующий PR ветки через `gitea-api`;
изменения остаются в одном PR #53.
- [ ] После каждого push независимый reviewer проверяет полный текущий PR,
прежние исправления и оставляет краткий PR-комментарий с явным выводом
о наличии или отсутствии блокирующих замечаний; reviewer не редактирует код.
- [ ] Исправить обоснованные замечания в той же ветке/PR, повторить проверки,
commit/push и независимый review.
- [ ] После успешных проверок и устранения блокирующих замечаний обновить
существующий changelog, записав значимые изменения для пользователя.
- [ ] Commit/push changelog в тот же PR, повторить обязательные проверки
и независимый review окончательного состояния.
Готовность к merge: проверки проходят, финальный review не содержит
блокирующих замечаний, changelog и версии согласованы, визуальные доказательства
доступны reviewer. Не сливать автоматически и не развёртывать production.
## 12. Порядок выполнения и итоговые материалы
Зависимости: эталон → измерения → разделение сценариев → ресурсы → композиция
→ поведение → адаптивность и состояния → визуальная приёмка → финальный PR.
Не начинать микроподгонку кнопок до исправления общей геометрии.
Ожидаемые материалы реализации:
- обновлённая поисковая страница и сохранённый сценарий лаборатории;
- локальные шрифты с лицензиями, SVG-знак, орнамент, favicon и иконки;
- детерминированный тестовый сценарий и воспроизводимый способ снимков;
- карта измерений, снимки, overlay/diff и отчёт приёмки;
- результаты обязательных проверок и независимого review;
- версии, changelog и один PR без автоматического merge.
Чекбоксы отмечать только после выполнения и проверки. Локальные проверки,
которые реально выполнены, зафиксированы в отчёте visual-review.
Этот документ не подтверждает готовность API, публичного продукта или
production.
+462
View File
@@ -0,0 +1,462 @@
# План реализации и приёмки Акылдаш
Дата актуализации: **2 ноября 2026 года**. Основа сверки: `main` на
`7ece39f212b67e497a8dfb360d5d271ab088ef57`. Это единый план работ по продукту,
интерфейсу и переходу к production. Он не заменяет эксплуатационные runbooks
и не подтверждает состояние живого сервиса.
## 1. Статус и границы достоверности
Различаем три вида сведений:
- **В коде** — наличие исходного кода на указанном commit; это не доказывает,
что функция развёрнута или доступна пользователям.
- **Историческое наблюдение** — результат конкретного аудита, дата и пределы
которого указаны рядом. Оно не является повторной проверкой текущего сайта.
- **Цель / gate** — требование, которое нужно выполнить и подтвердить до
следующего этапа.
На сверенном commit источники кода показывают Backend `0.13.3`
(`backend/search/minjust_opensearch.py`), Frontend `0.3.3`
(`backend/search/review.html`) и Telegram-бот `0.2.2`
(`tools/telegram-bot/bot.py`). Это версии исходного кода, а не подтверждение
установленных production-версий. В этом PR согласованы версии в корневом и
общем документационном README; `backend/README.md` отдельно называет версию
загрузчика `minjust_cbd.py` (`0.9.3`). При новых изменениях сверять версию
компонента с соответствующим исполняемым кодом.
В Search API и frontend реализованы поиск RU/KY, фильтры, карточка документа,
редакции, поиск в тексте, внутренняя лаборатория, вход и административные
действия. В коде также есть локальные Noto Sans/Serif с лицензиями, favicon и
встроенный SVG-знак бренда. Наличие файлов и ссылок в HTML не подтверждает,
что они загрузились в live-браузере. Результаты браузерной проверки приведены
только в [отчёте визуальной приёмки](visual-review/README.md).
Production readiness не следует из наличия Compose, release workflow или
deploy-кода. Подтверждённое состояние и ограничения находятся в
[CURRENT_STATE.md](../operations/CURRENT_STATE.md); статус релиза — в
[DEPLOYMENT.md](../operations/DEPLOYMENT.md), ограничения backup/restore — в
[BACKUP.md](../operations/BACKUP.md), rollback — в
[ROLLBACK.md](../operations/ROLLBACK.md), аварийное восстановление — в
[RECOVERY.md](../operations/RECOVERY.md). Этот план не повторяет команды из
этих runbooks.
## 2. Очерёдность работ
### 2.1 Продукт и качество правовых данных
1. Поддерживать воспроизводимое получение и нормализацию официальных
правовых материалов с сохранением источника, редакции и контрольных сумм.
2. Подтвердить полноту и актуальность RU/KY корпуса, правила для отсутствующих
текстов и одноязычных редакций; не выдумывать перевод или реквизиты.
3. Развивать замороженный relevance set и измерять качество на одной и той же
выборке до и после изменений ранжирования.
4. Развивать версионируемые API и справочники, сохраняя совместимость
существующих клиентов.
5. Публичный frontend, персональные функции и RAG остаются отдельными
продуктовыми решениями и readiness gates. Сверяться с [обзором проекта](project-overview.md),
[планом frontend](frontend-search-sps-plan.md) и
[готовностью публичного frontend](frontend-design-readiness-plan.md). Конкурентная
гипотеза и сегмент рынка отдельно описаны в
[стратегическом сравнении с Токтом](platform-development-plan-toktom-2026-10-24.md).
### 2.2 Интерфейс: датированный реестр UI-01—UI-33
Реестр ниже сохраняет исходные наблюдения аудита **23 октября 2026 года** и
критерии приёмки; это не список текущих открытых дефектов. Актуальный план на
`main` указывает, что все UI-01—UI-33 реализованы в PR #54. Он также описывает
follow-up по поиску, кабинету и лаборатории, локальную браузерную проверку и
синтетические regression fixtures. Их результаты привязаны к указанным в
исходном документе commits и окружению, а не являются повторным live-аудитом в
этой документационной задаче. Сверяться с [отчётом visual-review](visual-review/README.md);
не открывать снова выполненные пункты без нового воспроизводимого свидетельства.
Источники по состоянию на базовый commit этого PR сохранены постоянными
ссылками: [UI-план на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/interface-correction-plan.md)
и [план pixel-perfect на `7ece39f`](https://git.vakanaut.ru/admin/akyldash/src/commit/7ece39f212b67e497a8dfb360d5d271ab088ef57/docs/product/frontend-pixel-perfect-plan.md).
UI-01—UI-33 вошли в [PR #54](https://git.vakanaut.ru/admin/akyldash/pulls/54)
(merge commit [`01380d3c`](https://git.vakanaut.ru/admin/akyldash/commit/01380d3c09d71e32c965d974a39a4d82de0a15c5));
последующий follow-up поиска, кабинета и лаборатории записан в
[commit `e5a8326a`](https://git.vakanaut.ru/admin/akyldash/commit/e5a8326a5e3def1bf5d5dc94794da82a2af801ab).
В исходном реестре **P1** означает обязательное исправление до визуальной
приёмки/выпуска, **P2** — существенное улучшение понятности и
последовательности. Отсутствие функции само по себе не считается багом;
обозначенные целевые корректировки требуют подтверждения по коду и дизайну.
| ID | Приоритет | Историческое наблюдение 23.10.2026 | Требование / критерий |
|---|---|---|---|
| UI-01 | P1 | При пустом запросе одновременно показывались подсказка и empty-состояние | Разделить initial/loading/results/empty/error; empty показывать после завершённого поиска |
| UI-02 | P1 | До выбора документа были доступны действия и пустые select | До загрузки документа показывать понятное приглашение; действия доступны после выбора |
| UI-03 | P1 | Фильтры имели горизонтальное переполнение (`clientWidth=194`, `scrollWidth=231` при 1064 px) | Исключить переполнение; длинные списки раскрывать по запросу |
| UI-04 | P1 | Действие «Сбросить» обрезалось заголовком | Заголовок и действие видны целиком на поддерживаемых размерах |
| UI-05 | P1 | Счётчики отрывались от подписей, переносы растягивали панель | Разделить колонки выбора/подписи/числа; переносить только длинные названия |
| UI-06 | P1 | Все 28 видов и 8 статусов были раскрыты, орган и даты находились ниже первого экрана | Показывать основные значения и сворачиваемые группы, сохраняя доступ ко всему каталогу |
| UI-07 | P1 | Ширины около 78 px не хватало для RU/KY названий языка | Подбирать размер по самой длинной локализованной подписи без обрезки |
| UI-08 | P1 | На 390 px навигация прокручивалась горизонтально, кабинет и фильтры уходили за край | Адаптивно показывать поиск, фильтры и кабинет без горизонтальной прокрутки основных действий |
| UI-09 | P2 | Кабинет был малозаметной иконкой во второй строке | Подписанная desktop-кнопка; на mobile — доступное имя и достаточная touch-область |
| UI-10 | P1 | Три колонки оставались тесными на 1024/1064 px | Перейти к двухпанельному режиму и скрытию фильтров, не сжимать колонки бесконечно |
| UI-11 | P2 | Название документа повторялось в breadcrumb, заголовке и тексте | Breadcrumb обозначает контекст; официальный текст не переписывать |
| UI-12 | P1 | Ссылка на источник и toolbar переполнялись на промежуточной ширине | Переносить действия группами без обрезки; сохранять подписи редакции и языка |
| UI-13 | P2 | Пустые абзацы и синие ссылки конфликтовали с оболочкой | Нормализовать отступы и ссылки без потери структуры официального текста |
| UI-14 | P1 | Таблицы в документе шире панели (`clientWidth=412`, `scrollWidth=834`) | Дать таблице отдельную обозначенную прокрутку; не прокручивать всю страницу по горизонтали |
| UI-15 | P1 | Встречались Temp-пути и HTTP-ссылки исходного редактора | Классифицировать ссылки безопасно, не удалять юридический текст; не считать локальные/IP-ссылки проверенными |
| UI-16 | P2 | Развёрнутый документ терял поиск, совпадение и toolbar | Сохранять редакцию, язык, позицию и навигацию; обеспечить очевидный возврат |
| UI-17 | P2 | Часть KY-интерфейса оставалась на русском | Инвентаризировать строки интерфейса; не заменять переводом язык документа |
| UI-18 | P1 | На `/review` отображались два footer, общий footer был выше сохранения | Один footer после основного содержимого и панели сохранения |
| UI-19 | P1 | Шапка, формы, панели и footer лаборатории имели разные направляющие | Единая оболочка и совпадающие границы контента |
| UI-20 | P1 | Высокая шапка лаборатории отодвигала работу на mobile | Компактный заголовок и помощь; поиск и рабочая область доступны сразу |
| UI-21 | P1 | Заголовок кабинета был пустым | Видимый заголовок и доступное имя диалога |
| UI-22 | P1 | Не было постоянного крестика; закрытие находилось внизу формы | Крестик 44×44, доступное имя, Escape и возврат фокуса; отмена редактирования остаётся |
| UI-23 | P1 | Навигация, выход, профиль и смена пароля были смешаны | Разделить «Профиль» и «Безопасность», отделить выход от формы |
| UI-24 | P1 | Окно пользователей открывалось поверх кабинета | Не оставлять конкурирующие модальные окна; сохранять контекст и корректный фокус |
| UI-25 | P1 | Список пользователей был длинным модальным окном с закрытием ниже экрана | Отдельный экран списка и короткие диалоги для создания/сброса |
| UI-26 | P2 | Создание, удаление, сброс и входные данные смешивались | Действия на строке, отдельное подтверждение удаления, не раскрывать лишние формы |
| UI-27 | P2 | Бренд, навигация, шрифты и ссылки различались между экранами | Общие header, logo, button, link, dialog и footer |
| UI-28 | P2 | Диалоги закрывались разными способами | Единая модель окна и доступного закрытия |
| UI-29 | P2 | Обучение появлялось с задержкой и перехватывало ввод | Запуск по запросу или ненавязчиво до начала ввода; не отбирать фокус |
| UI-30 | P2 | Шкала 0–3 переносилась, комментарии всегда занимали место | Полные подписи; допустима сетка 2×2; раскрываемый комментарий без потери текста |
| UI-31 | P2 | Пустой экран лаборатории выглядел готовым к отправке оценки | Явное состояние снимка, прогресс и статус черновика; не менять смысл пустой оценки |
| UI-32 | P2 | Footer live-сайта показывал «Концепция Түндүк» | Рабочий интерфейс показывает продукт и реальные версии; концепция подписывается только в макете/демо |
| UI-33 | P1 | Документ растягивал лабораторию до y≈636935 px, сохранение уходило ниже | Ограничить высоту области чтения и прокручивать её отдельно; сохранение доступно независимо от длины текста |
Дополнительные целевые критерии исходного аудита:
- **Сетка и ресайз колонок:** desktop — три колонки с доступными разделителями;
ориентиры 1440 px: `280 / 408 / 736` плюс два захвата по 8 px, 1280 px:
`256 / 384 / 624` плюс захваты. Рекомендуемые пределы фильтров 256–360 px,
выдачи от 360 px, документа от 560 px. При недостатке ширины сначала скрыть
фильтры, затем показывать документ отдельно.
- Разделители должны иметь `role=separator`, доступное имя, min/max/current
значения, pointer drag и управление стрелками клавиатуры. Отдельные кнопки
скрытия/показа фильтров и сброса ширины обязательны; двойной клик не заменяет
доступный сброс. Сохранённая ширина ограничивается текущим viewport.
- Остальные правила сетки: фильтры не становятся постоянно прокручиваемой
узкой колонкой; полный каталог значений всегда достижим; API single-select не
изображается множественными checkbox. Длинное название органа читается в
панели выбора полностью. Поля дат имеют постоянные «От» / «До» и переходят
в столбец, когда не помещаются. На desktop выдача и документ могут иметь
отдельную вертикальную прокрутку с доступными заголовком/toolbar; на mobile
основной поток вертикальный с отдельными панелями. Footer использует
растущий main (`min-height: 100dvh`), не перекрывает контент и следует за ним;
панель сохранения имеет собственное место.
- **Панели и формы:** понятный заголовок и крестик 44×44; именованный modal
dialog, фокус внутри, Tab/Shift+Tab без выхода в фон, Escape там, где закрытие
допустимо, возврат фокуса инициатору; фон не интерактивен. Не наслаивать
кабинет и пользователей. Профиль и безопасность — отдельные разделы; права
по-прежнему проверяет сервер. В форме пароля постоянные подписи, требования,
доступное показать/скрыть и подтверждение нового пароля. Пароли не
предзаполнять и не сохранять в материалах аудита; ввод выполняет человек.
- Основное действие одно; отмена вторична, выход отдельно; Enter отправляет
только активную форму. Ошибка у поля и в доступном сообщении, допустимый ввод
сохраняется, loading исключает повторную отправку. Несохранённое редактирование
защищено от случайной потери, обычный просмотр закрывается без лишнего
подтверждения. После успеха сообщается результат, при ошибке есть путь
восстановления; состояние не кодируется одним цветом.
- Правила применяются к кабинету, входу, обязательной смене пароля, источникам,
созданию/сбросу/удалению пользователя, обучению и просмотру документа.
- **Приёмка:** единая оболочка/header/footer; нет общей горизонтальной
прокрутки; initial не сообщает об empty до поиска; действия документа
активируются по состоянию; keyboard и touch работают; RU/KY подписи и язык
документа независимы; таблицы прокручиваются отдельно; тестируются contrast
WCAG AA, видимый focus, reduced motion, zoom/reflow и доступные имена.
- Viewport: **1440×900, 1280×800, 1024×768, 390×844**, дополнительно 320 CSS px
для reflow. Визуальное сравнение — детерминированное fixture; нулевой
pixel-diff не является критерием. Авторизация и destructive-сценарии
проверяются только в безопасном тестовом контуре.
Исторически выполненные браузерные сценарии и ограничения аудита 23.10.2026:
| Область | Выполнено в том аудите | Не проверено тогда |
|---|---|---|
| Начальная страница и поиск RU | Пустой запрос, фильтры, footer; поиск «налог на прибыль», открытие кодекса | Экспертная оценка юридической релевантности |
| Фильтры, сортировка, страницы | Статус, chip, снятие фильтра, дата/релевантность, страница 2 и сброс на страницу 1 | Все комбинации справочников |
| Пустая выдача и документ | Контрольный заведомо пустой запрос, реальные текст и редакции, язык, разворот | Искусственное создание серверного сбоя, реальная печать/PDF |
| Поиск в документе и локализация | Совпадения, переход к следующему, переключение RU/KY и чтение подписей | Поиск после смены редакции; полная проверка всех переводов |
| Источники и мобильные фильтры | Открытие/закрытие панели и раздела источников | Внешний сайт Минюста, полный touch на физическом устройстве |
| Кабинет и пользователи | Профиль, поля смены пароля, Escape/возврат фокуса; просмотр списка | Ввод пароля, создание/удаление пользователя, сброс пароля |
| `/review` | Пустое состояние, поиск, шкала, документ, обучение | Отправка оценки и подтверждение серверного сохранения |
| Авторизация | Наличие локальной реализации | Live login, истечение сессии и обязательная смена пароля |
Этот перечень описывает только прежний браузерный сеанс. Не использовать его
как подтверждение текущих production-сценариев.
#### Реализация, зафиксированная в текущем плане `main`
UI-план, находившийся на базовом commit этого PR, отмечает UI-01—UI-33
реализованными в PR #54 и связывает группы изменений с
регрессионными сценариями:
| Пункты | Изменение, зафиксированное в плане `main` | Regression evidence, указанное там |
|---|---|---|
| UI-01—02 | Разделены initial/loading/results/empty/error; действия документа зависят от выбранной редакции | Empty, API error и редакция без текста |
| UI-03—08 | Компактные/сворачиваемые фильтры, счётчики, локализованные языковые select и адаптивная навигация | Reflow 320/390/640/1024/1280/1440; на 640 px проверены основные действия `/`, `/review`, `/users` |
| UI-09—10 | Подписанный кабинет и адаптивный layout вместо бесконечного сжатия трёх колонок | Геометрия и overflow на 640/1024/1280/1440 |
| UI-11—16 | Breadcrumb, toolbar, отдельный scroll таблиц, очистка небезопасных URL-атрибутов и разворот документа в панели | Синтетические private-IP/relative/HTTP ссылки; ошибки и гонки редакций A/B |
| UI-17 | Перевод интерфейсных подписей RU/KY с независимым языком документа | Переключение языка; полный ручной лингвистический review остаётся открытым |
| UI-18—20 | Один footer, общие направляющие и ограниченная по высоте лаборатория | Один footer и длинный синтетический документ с собственным scroll |
| UI-21—24 | Имя и разделы кабинета, безопасный переход к пользователям без наложенных диалогов | ARIA name, Escape, focus trap/return и отдельные маршруты |
| UI-25—28 | Отдельный экран пользователей, целевые окна для действий, общий способ закрытия | Только фиктивный `fixture-created`; реальные аккаунты/API не использовались |
| UI-29 | Обучение запускается пользователем | Локальный regression scenario |
| UI-30—31 | Оценка 2×2, раскрываемый комментарий, disable до результата и draft state | Фиктивное сохранение и сохранность оценок при ошибке/устаревшем ответе |
| UI-32—33 | Версии в footer и отдельная прокрутка длинного текста лаборатории | Footer versions и длинный fixture, сохранение остаётся доступно |
Тот же план `main` фиксирует локальный browser run на **320×800, 390×844,
640×450, 1024×900, 1280×800 и 1440×900 CSS px**; на 1280 px измерена
сетка 256 / 384 / 640 px. Экран лаборатории с длинным текстом имел собственную
панель прокрутки, а сохранение/footer оставались доступны. Встроенная
проверка fixture не использовала production API или реальные учётные записи.
Это точная передача прежнего отчёта; её не следует трактовать как результат
нового прогона в этом PR. Полный screen-reader review, 200–400% zoom,
физический touch и полный ручной лингвистический review в исходном плане
оставлены непроверенными.
Отдельный follow-up в плане `main` описывает реализованные после PR #54
корректировки и regression coverage:
- **Поиск:** «Открыт» сбрасывается при смене результата/редакции и ставится
только после успешной загрузки. Оглавление распознаёт самостоятельные
заголовки RU/KY из структурированных абзацев (`p > b > span`), задаёт
уникальные якоря и вложенность, не принимает обычные упоминания за заголовки.
Выбор языка поиска находится в фильтрах и не обрезается; декоративный SVG и
пустое резервное место удалены; активная навигация использует палитру.
Проверены ошибки и гонки загрузки, структура заголовков, splitter и
доступность диапазона его ширины.
- **Кабинет:** ссылки действий сохраняют `<a>` семантику, крестик остаётся
подписанной SVG-кнопкой 44×44, значения профиля стоят в парах «поле—значение»,
длинные значения переносятся, заголовок компактнее; администратору явно
доступно «Управление пользователями». Проверены RU/KY, отсутствие ссылки у
не-администратора, фокус/закрытие и ширина.
- **Лаборатория:** имя показывается без дублирующей роли; ссылки навигации имеют
вид кнопок; оболочка, форма, рабочая область, сохранение и footer выровнены.
Пустой live status не резервирует высоту, результаты и документ образуют
общую поверхность с внутренней прокруткой на desktop, заголовок использует
доступную ширину, а mobile возвращается к естественному потоку. Проверены
desktop/mobile, пустой status, сохранение после длинного документа и page
overflow.
Исходный план сообщает о локальной проверке этих изменений 24 октября 2026
года и повышении frontend до `0.3.0` из-за регулируемой ширины панелей. Текущий
source footer и версия кода — `0.3.3`. Его отметка «ожидаются CI и review»
относилась к конкретному pushed SHA и не задаёт статус этого или любого нового
PR.
#### Общие критерии окон и форм
- Desktop-окно обычно 480–640 px и не шире viewport; на mobile — отступ 16 px
или полноэкранный режим для длинной формы. Заголовок и закрытие остаются
доступны при прокрутке.
- Диалог именуется через заголовок, держит фокус внутри и не делает фон
интерактивным. Escape закрывает обычные окна; исключения (например,
обязательная смена пароля) должны объясняться, а не маскироваться.
- Профиль и безопасность разделены. Удаление требует отдельного подтверждения;
создание/сброс не раскрывают дополнительные формы без запроса.
- Поля имеют постоянные подписи; пароль не предзаполняется и не записывается
в отчёт. Ошибки объясняют восстановление, допустимый ввод сохраняется,
повторная отправка во время loading исключена.
- «Отмена» вторична, выход не смешивается с сохранением; несохранённое
редактирование защищено от случайной потери. Обычный просмотр закрывается
без лишнего подтверждения.
#### Последовательность при возобновлении UI-работ
1. Зафиксировать актуальные SHA и версии; читать маршрут, API, `DESIGN.md`,
UI tests и CI. Не переносить состояние live-сайта без новой проверки.
2. Исправить общую оболочку и разделение поиска, лаборатории и служебных
экранов; затем initial/loading/empty/error и жизненный цикл документа.
3. Исправить фильтры, адаптивные минимумы и RU/KY; после этого — регулируемые
колонки и скрытие фильтров.
4. Привести формы/диалоги к общей модели с сохранением серверной авторизации,
прав, валидации, снимков оценок и черновиков.
5. Проверить текст, таблицы, ссылки, редакции, печать, адаптивность,
локализацию, доступность и состояния на реальном корпусе, где доступен.
6. Сделать детерминированный fixture-проход и независимый review; обновить
версию только при изменении кода, а changelog — после зелёных проверок и
разрешения blocking findings.
Не переносить существующий Python frontend на React/Next.js ради визуальной
корректировки. UI-работа не меняет ранжирование, корпус или права доступа.
| Приёмка | Условие |
|---|---|
| Оболочка | Один header/footer на маршруте, единые границы и порядок контента |
| Фильтры | Нет общей горизонтальной прокрутки; подписи/действия/счётчики видны полностью |
| Начальное состояние | До первого завершённого запроса empty-сообщение не показано |
| Документ | До выбора неактивные действия не вводят в заблуждение |
| Колонки | Pointer и клавиатура; min/max; скрытие/возврат; reset; адаптация сохранённой ширины к viewport |
| Телефон | Основные действия без горизонтального скролла; понятный возврат из фильтров/документа |
| Окна | Заголовок, крестик, Escape, trap/return focus; окна не конкурируют слоями |
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение; текст не теряется, панель доступна при длинном документе |
| Локализация | RU/KY labels и символы; язык UI не подменяет язык документа |
| Геометрия | При 1280×800 границы основных зон и отступы/размеры controls отличаются от измеренного fixture не более чем на 2 CSS px; согласованные исключения описаны |
| Типографика и таблицы | Noto действительно загружается; читаемый длинный текст; у широкой таблицы собственный скролл |
| Доступность | Контраст AA, focus, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion и touch targets |
| Авторизация | Неверный пароль, expired session и обязательная смена проверены только в безопасном тестовом контуре |
| Снимки | 1440×900, 1280×800, 1024×768, 390×844 и 320 CSS px; стабильные fixture и объяснённые различия |
Допуск **до 2 CSS px** для границ крупных зон и отступов/размеров controls при
1280×800 перенесён из исторического pixel-perfect плана по стабильной ссылке
выше. Он применяется к одинаковым детерминированным fixture; расхождения
растра, вызванные сглаживанием текста или содержимым, сами по себе не дают
основания обрезать или уплотнять юридический текст.
#### Нормативный орнамент и фактическое состояние
`DESIGN.md` нормативно требует небольшой золотистый SVG-орнамент в свободной
нижней части desktop-панели фильтров, не перекрывающий длинные списки и не
резервирующий место за счёт управления. Follow-up из commit `e5a8326a` удалил
предыдущий декоративный SVG и его пустой резерв; в `backend/search/review.html`
на базовом `main` целевого орнамента нет. Это не выполненное требование, а
подтверждённое расхождение между `DESIGN.md` и реализацией. Дизайн остаётся
нормативным до отдельного решения владельца: UI-задача должна либо вернуть
орнамент в свободную область без влияния на управление, либо получить явное
одобрение на изменение `DESIGN.md` и подтвердить его визуальной приёмкой.
### 2.3 Инфраструктура и переход к production
Инфраструктурные источники содержали повторяющиеся этапы deployment и
устаревшие снимки VPS. В этом плане оставлены только gates; актуальные
подтверждённые данные и процедуры находятся в операционных документах.
1. **Аудит:** сверить commit `main`, CI, Compose, зависимости, порты, volumes,
healthchecks, данные и перечень env-переменных без вывода секретов. Для VPS
пользоваться только read-only инвентаризацией, разрешённой
[правилами доступа](../operations/ACCESS.md). Не переносить IP, версии,
DNS-статус или ресурсы из старых планов как текущие факты.
2. **Данные и конфигурация:** установить источник и допустимость корпуса,
формат, состав, checksum/manifest, размер и воспроизводимую цепочку импорта.
Обязательный gate любой передачи/публикации корпуса — процедура
[CORPUS_HANDOFF.md](../operations/CORPUS_HANDOFF.md): source → staging →
inventory/manifest и checksum comparison → атомарная публикация
проверенного staging-корпуса в `DATA_ROOT/minjust-normalized` → отдельная
проверка и импорт в индекс → OpenSearch alias switch. Runbook требует
непустые source и staging, явное решение по исключению code 6 и fail-closed
при неполных/несовпадающих данных. Атомарная файловая публикация и
возобновление/отказ после прерывания — обязательный, но ещё не реализованный
gate; до описания и проверки all-or-nothing механизма не заменять им
действующий корпус. Не смешивать его с атомарным переключением OpenSearch
alias. Следовать runbook без копирования его команд сюда; до прохождения
gates корпус в production не передавать и не индексировать. Не подменять
корпус пустым каталогом. Production env/секреты создаются вне Git; в
репозитории остаются только имена переменных и шаблоны без значений.
3. **Build/CI:** PR-проверки не получают production secrets и Docker socket.
Публикация образов должна фиксировать digest и provenance; release workflow
не равен deployment. Production CD не включать до прохождения owner gates,
безопасной сборки, backup и отдельного restore.
4. **Контролируемое изменение существующего production:** последняя датированная
проверка `https://akyldash.kg/review` вернула HTTP 200 25 октября 2026 года
([CURRENT_STATE](../operations/CURRENT_STATE.md)); установленный на хосте
SHA остался неизвестен. План относится к безопасному
обновлению существующего сервиса, а не к первому развёртыванию. До изменения
проверять реальные данные и конфигурацию, доступное место, опубликованные
порты, права, совместимость схем и backup readiness. Выполнять через
существующую ограниченную процедуру; не добавлять общие sudo/Docker-
привилегии. Базы данных и внутренние сервисы не публиковать в интернет.
DNS, reverse proxy и TLS
проверять до выпуска; для Cloudflare применять Full (strict). SSH/UFW и
сетевые ограничения меняются только после owner approval.
5. **Проверка и recovery:** проверять health/API, RU/KY canaries, доступность
HTTPS, сохранность данных и rollback в пределах его документированных
ограничений. Data restore — отдельная процедура в staging, не автоматический
побочный эффект отката образа.
6. **Автоматизация:** только после одобренного отдельного bootstrap и
end-to-end проверки; serial deployment, digest verification, backup ACK,
health gates и отказ без destructive cleanup. Не включать триггер только
потому, что workflow завершился зелёным.
**Backup/maintenance gate:** настройки CI, проверка конфигурации и подготовка
release provenance должны быть отделены от production cutover. Production CD
в `CURRENT_STATE.md` остаётся неактивным. Перед каждым фактическим production
изменением, включая миграции, создать и проверить внешний pre-change backup.
Полный cold backup требует остановки Compose и может выполняться только в
заранее согласованное с владельцем окно.
Ни полный архив, ни его временная копия никогда не записываются на VPS. По
текущей утверждённой схеме архив передаётся напрямую на фиксированный
`D:\Dev\akyldash\.backups`; фактическая готовность D:, SSH и отдельное
восстановление всё ещё не подтверждены. На VPS остаётся только secret-free
идентификатор/manifest согласно `BACKUP.md`. Политика — одна retained,
проверенная внешняя копия. При замене прежняя копия сохраняется до тех пор,
пока новый candidate не опубликован и не проверен; на время этой проверки две
копии могут сосуществовать. После подтверждения новой копии retention может
изменить только владелец явным решением; автоматическая очистка запрещена.
При неудачной публикации или проверке прежняя подтверждённая копия остаётся.
Локальные VPS cold-backup команды из старых документов superseded и
использоваться не должны.
#### Локальные generated reports
`/reports/` и `/tmp/` игнорируются по умолчанию, чтобы generated bulk не
попадал в обычный `git status`. Уже отслеживаемые historical evidence остаются
отслеживаемыми. Если конкретный отчёт нужен в Git, сначала проверить его
содержимое, удалить секреты, персональные данные, production corpus и
временные файлы, затем добавить только предназначенный к публикации файл
явной командой `git add -f reports/<path>`. Не добавлять каталоги целиком.
Нынешний статус не переопределяется этим roadmap: на сверенной документации
production CD остаётся неактивным, а backup/restore и внешний bootstrap имеют
непроверенные owner prerequisites. Все точные блокировки сверять с
`CURRENT_STATE.md` и `DEPLOYMENT.md` перед планированием действий.
## 3. Визуальные материалы и исходная база
### Макеты Figma
Целевые редактируемые экраны — гипотезы компоновки, а не работающий интерфейс,
источник юридических данных или доказательство доступности.
| Экран | Макет |
|---|---|
| 01. Поиск с документом, 1440×900 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
| 02. Фильтры скрыты | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
| 03. Начальный поиск | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
| 04. Лаборатория и сохранение | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
| 05. Кабинет: профиль и закрытие | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
| 06. Кабинет: безопасность | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
| 07. Источники | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
| 08. Пользователи: отдельный экран | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
| 09–11. Телефон: выдача, чтение и фильтры (общий Figma-узел) | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
| 12. Эталон 1280×800 | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
| 13. Планшет: две панели | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
| 14. Кабинет на телефоне | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
| 15. Вход, создание и подтверждение удаления | [Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
Локальные target exports: [поиск](../design/tunduk-search-target.png),
[лаборатория](../design/tunduk-review-target.png),
[кабинет](../design/tunduk-account-target.png). Это изображения цели; они не
заменяют браузерную проверку.
В Figma экраны собраны из редактируемого текста, Auto Layout, SVG, локальных
переменных цвета/отступов и общих экземпляров кнопок; это не один растр.
Состояния 09–11 ведут на один общий мобильный узел, как в исходном плане.
Прокрутка длинной выдачи/текста показывает продолжение содержимого.
Splitter, закрытие и формы описывают целевое поведение, но не являются
работающим web-кодом.
### Исторические свидетельства
Аудит UI выполнен **23 октября 2026 года** в авторизованной сессии на
`https://akyldash.kg/`, где footer показывал Frontend `0.2.3`, Backend
`0.13.0`. Сверка SHA сервиса с checkout не выполнялась. Проверены
1440×900, 1280×800, 1024×768, 390×844 CSS px и дополнительный browser viewport
1064×1244. Пароли не вводились; создание/удаление пользователей и отправка
оценки не выполнялись; полный физический touch, screen-reader и принтерный
прогоны не выполнялись. Существующие локальные browser evidence и screenshots
собраны в [отчёте visual-review](visual-review/README.md). Ветка `main` не
содержит отдельный архив live screenshots этого аудита. Любое использование
результатов требует повторной проверки актуальной версии и окружения.
## 4. Постоянные правила дизайна и полномочия
[DESIGN.md](../../DESIGN.md) остаётся нормативной дизайн-системой: цветовые
токены, типографика, бренд, композиционные принципы и доступность не
дублируются здесь. Этот roadmap задаёт работы, исторические критерии и gates;
он не утверждает новый дизайн и не разрешает production deployment.
Все пункты плана отмечать выполненными только при наличии актуального
свидетельства. Изменения приложения проходят обычные тесты, версионирование,
CI и независимое review. Инфраструктурные изменения отражаются в
`CURRENT_STATE.md` только по фактическим проверкам. Никакие действия с
production, секретами, пользователями или правовым корпусом не следуют из
наличия этого документа. Root-only bootstrap и предоставление секретов
выполняет/утверждает владелец в защищённой среде; DEV готовит проверяемый код
и точный план, reviewer независимо проверяет безопасность и failure paths.
-334
View File
@@ -1,334 +0,0 @@
# Единый план корректировки интерфейса «Акылдаш»
Дата аудита: **23 октября 2026 года**.
Базовые пункты UI-01—UI-33 исправлены и вошли в `main` через PR #54.
На 24 октября 2026 года отдельный follow-up по замечаниям владельца для поиска,
личного кабинета и лаборатории реализован; локальный браузерный набор пройден.
Ожидаются CI и ручное PR review для pushed SHA.
Этот файл заменяет `frontend-pixel-perfect-plan.md` и является единственным
рабочим планом исправления UI/UX поиска, лаборатории и служебных окон.
Продуктовый roadmap и требования к юридической готовности остаются отдельными
документами: они не являются конкурирующими планами исправления интерфейса.
## 1. Источники истины и границы
- [DESIGN.md](../../DESIGN.md) — нормативная система «Түндүк».
- [Утверждённая композиция](../design/tunduk-desktop-concept.png) — визуальный
ориентир, а не образец юридических данных или текущих функций.
- [Целевые редактируемые макеты Figma](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB)
— предлагаемая компоновка поиска, лаборатории, кабинета и адаптивных видов.
- Этот файл — дефекты, требования к поведению, порядок исправления и приёмка.
Проверялся работающий `https://akyldash.kg/` в авторизованной сессии,
версии в интерфейсе: Frontend **0.2.3**, Backend **0.13.0**.
Локальный checkout содержит более ранние версии; код и live-сайт нельзя
считать одинаковыми без сверки SHA перед реализацией. Инфраструктурные
документы не заменяют результаты этой браузерной проверки.
В этой задаче исправляются frontend и доступ к статическому экрану `/users`.
Backend-проверки ролей и операций управления пользователями не меняются.
Production, пользователи, пароли, сохранённые оценки, ранжирование и правовой
корпус не меняются. Реальные учётные записи не используются в регрессионном
fixture.
## 2. Что проверено
Проверка включала клики и чтение DOM, снимки и измерение переполнения.
Контрольные viewport: **1440×900, 1280×800, 1024×768, 390×844**;
дополнительно исходный viewport браузера 1064×1244. Изображения пользователя
с более широким desktop учитываются как дополнительные свидетельства,
но не выдаются за собственный браузерный прогон.
| Поверхность / сценарий | Выполнено | Ограничение |
|---|---|---|
| Начальный `/` | Пустой запрос, доступные действия, фильтры, footer | Ошибочное empty-состояние подтверждено |
| Поиск RU | Запрос «налог на прибыль», загрузка выдачи, открытие кодекса | Не экспертная проверка юридической релевантности |
| Фильтры | Выбор статуса, появление chip, снятие фильтра | Все комбинации справочников не перебирались |
| Сортировка / страницы | Выбор даты / релевантности, переход на страницу 2, сброс на страницу 1 | Оценивалось состояние UI и URL |
| Пустая выдача | Запрос `zzzzqwerty987654321` | Серверный сбой не провоцировался |
| Документ | Реальный текст, список редакций, переключение редакции и языка, разворот | Печать на принтер / в PDF не запускалась |
| Поиск в документе | Ввод фразы, подсветка, кнопка следующего совпадения | Поиск после смены редакции требует отдельного теста |
| RU/KY интерфейс | Переключение и чтение подписей | Найдены непереведённые подписи; полнота всех строк не подтверждена |
| Мобильные фильтры | Открытие и закрытие панели | Полный touch-прогон на физическом устройстве не выполнен |
| «Об источниках» | Открытие, чтение, закрытие | Внешний сайт Минюста не является частью этого аудита |
| Личный кабинет | Открытие, профиль, поля смены пароля, Escape и возврат фокуса | Пароли не вводились и не менялись |
| Пользователи | Открытие из кабинета и из лаборатории, чтение структуры | Создание, удаление и сброс пароля не выполнялись |
| `/review` | Пустое состояние, поиск, шкала оценок, открытие документа, обучение | Оценки не отправлялись; серверное сохранение не подтверждено |
| Авторизация / истёкшая сессия | Установлено наличие в локальной реализации | Live login и принудительная смена пароля не проверялись: действующую сессию не завершали |
Снимки: [каталог аудита](../design/audit-2026-10-23/).
Участки с реальными списками пользователей не переносятся в макеты Figma;
там используются явно условные примеры. Часть снимков лаборатории сделана
с длинной страницей; снимок не гарантирует корректность всех скрытых участков.
## 3. Реестр недостатков
**P1** — исправить до визуальной приёмки / выпуска; **P2** — существенное
улучшение понятности и последовательности. Предложения обозначены отдельно
от подтверждённых дефектов. Отсутствие функции само по себе не означает баг.
| ID | Приоритет | Подтверждённый недостаток / свидетельство | Корректировка и условие приёмки |
|---|---|---|---|
| UI-01 | P1 | В начальном поиске одновременно «Введите запрос» и «Документы не найдены» | Разделить initial/loading/results/empty/error; empty только после завершённого поиска |
| UI-02 | P1 | До выбора документа доступны печать/разворот/содержание, видны пустые селекты | До выбора — только понятное приглашение открыть результат; инструменты появляются после загрузки |
| UI-03 | P1 | У фильтров горизонтальный и вертикальный скроллы; при 1064 px `clientWidth=194`, `scrollWidth=231` | Горизонтальное переполнение исключено; компактные группы, достаточная ширина, длинные списки раскрываются по запросу |
| UI-04 | P1 | «Сбросить» обрезается заголовком панели | Заголовок и действие полностью видны на всех поддерживаемых размерах |
| UI-05 | P1 | Счётчики отрываются от названий, частые переносы превращают фильтры в длинную колонку | Отдельные колонки выбора/подписи/числа; числа с разделителями; перенос только длинного названия по словам |
| UI-06 | P1 | Все 28 видов и 8 статусов развёрнуты; орган и даты далеко ниже первого экрана | Основные варианты + «Все виды»/«Все статусы», сворачиваемые группы; сохранять доступ ко всем значениям |
| UI-07 | P1 | Язык поиска имеет ширину около 78 px; «Русский»/«Кыргызский» обрезаются | Размер по самой длинной локализованной подписи и стрелке; проверка RU/KY без ellipsis |
| UI-08 | P1 | На 390 px строка навигации горизонтально прокручивается, кабинет и фильтры уходят за край | Адаптивная перестройка: видимые поиск, фильтры, кабинет; ни одна основная кнопка не требует горизонтального скролла |
| UI-09 | P2 | Кабинет — малозаметная иконка в правом конце второй строки; слева большой неинформативный промежуток | На desktop подписанная кнопка «Кабинет» в шапке; на mobile узнаваемая иконка с доступным именем и достаточной областью |
| UI-10 | P1 | На 1024/1064 три колонки остаются тесными; название кодекса занимает большую часть панели | Адаптивный двухпанельный режим, скрытие фильтров, ограничения ширин; не сжимать все три колонки бесконечно |
| UI-11 | P2 | Длинное название документа повторено в breadcrumb, заголовке и исходном тексте | Breadcrumb обозначает контекст; в заголовке короткое название при наличии отдельного поля, реквизиты ниже; официальный текст не переписывать |
| UI-12 | P1 | Source link и toolbar переполняются на промежуточных ширинах; при 1064 px toolbar `412/427` | Действия переносятся группами, без обрезки; размер по содержимому, подписи редакции и языка постоянные |
| UI-13 | P2 | В исходном тексте большие пустые абзацы и синие ссылки конфликтуют с оболочкой | Нормализовать отображение пустых блоков без потери структуры; безопасно унифицировать ссылки и отступы в scope документа |
| UI-14 | P1 | В тексте есть широкие таблицы: например, `clientWidth=412`, `scrollWidth=834` | Таблицы — самостоятельная обозначенная область прокрутки; документ и весь экран не получают горизонтальный скролл |
| UI-15 | P1 | В DOM документа обнаружены относительные ссылки на старые локальные Temp-пути и HTTP URL исходного редактора | Не показывать их как рабочие внутренние переходы; безопасно разрешать/классифицировать ссылки, не удаляя юридический текст; локальные/IP-ссылки не открывались |
| UI-16 | P2 | Разворот открывает ещё одно окно с текстом; поиск, выбранное совпадение и toolbar не представлены в нём | Целевой режим чтения сохраняет редакцию, язык, положение и средства навигации; возврат очевиден |
| UI-17 | P2 | Часть KY-интерфейса остаётся русской: например заголовки групп и служебные доступные подписи | Инвентаризировать все UI-строки; контент документа переводом интерфейса не подменять |
| UI-18 | P1 | На `/review` два видимых footer; общий footer находится до сохранения | Ровно один видимый footer после основного контента и панели сохранения |
| UI-19 | P1 | В лаборатории шапка, формы, панели и footer имеют разные границы и ширины | Общая оболочка и единые горизонтальные направляющие, совпадающие поля и ширины контейнеров |
| UI-20 | P1 | Шапка лаборатории слишком высокая; на mobile рабочие результаты вытеснены далеко вниз | Компактный заголовок и помощь рядом; поиск и начало работы доступны без большого вступительного блока |
| UI-21 | P1 | У кабинета фактически пустой `account-heading`, нет видимого заголовка | «Личный кабинет» в заголовке, корректное имя диалога для скринридера |
| UI-22 | P1 | В кабинете нет крестика; «Закрыть» только внизу формы смены пароля | Единый крестик 44×44 справа сверху, доступное имя, Escape, возврат фокуса; нижняя отмена при редактировании |
| UI-23 | P1 | Навигация, выход, профиль и смена пароля смешаны в одном неструктурированном окне | Отдельные разделы «Профиль» / «Безопасность»; служебные действия и выход отделены от формы |
| UI-24 | P1 | «Пользователи» из кабинета открывает `users-dialog`, когда `account-dialog` остаётся открытым поверх него | Не оставлять конкурирующие модальные окна: закрыть кабинет с сохранением контекста и открыть целевой экран/диалог; фокус соответствует видимому окну |
| UI-25 | P1 | Управление пользователями — длинный модальный список, закрытие ниже видимой области | Предпочтительно отдельный экран с таблицей; создать/сбросить — короткие отдельные диалоги с фиксированным заголовком и крестиком |
| UI-26 | P2 | Удаление, сброс пароля, создание и данные входа перемешаны без ясной иерархии | Действия на строке пользователя; удаление отдельно и с подтверждением; не раскрывать все формы сброса постоянно |
| UI-27 | P2 | Различаются оформление бренда/навигации/шрифтов, есть стандартные синие ссылки в лаборатории и кабинете | Единые header, logo, button, link, dialog и footer на всех поверхностях |
| UI-28 | P2 | Диалоги закрываются разными кнопками в разных местах | Одна модель окна и доступного закрытия, описанная ниже |
| UI-29 | P2 | Обучение автоматически появляется с задержкой и перехватывает фокус уже начатого ввода | Не перехватывать ввод; запуск по «Как это работает» либо ненавязчивому предложению до начала работы |
| UI-30 | P2 | Шкала 0–3 на длинной выдаче создаёт плотный набор переносов; комментарии всегда занимают место | Полные подписи без обрезки, допускается сетка 2×2; комментарий раскрывается, введённый текст не теряется |
| UI-31 | P2 | На пустом лабораторном экране «Сохранить оценку» выглядит готовой к отправке | Состояние зависит от наличия снимка; явное объяснение, прогресс и статус черновика; не менять смысл отсутствующей оценки |
| UI-32 | P2 | В footer работающего сайта остаётся «Концепция Түндүк» | В рабочем режиме название продукта и реальные версии; подпись концепции только в макете/демо |
| UI-33 | P1 | Открытие кодекса в лаборатории растягивает страницу: footer оказался на y≈636935 px, сохранение — внизу после него | Ограничить область чтения по высоте viewport и дать ей собственную вертикальную прокрутку; сохранение доступно независимо от длины документа, без сотен экранов прокрутки |
Escape и возврат фокуса кабинета на `search-account-open` проверены и работают.
Это поведение сохранить. Механизм Tab/Shift+Tab всех окон, ошибки API,
контраст всех состояний, печатная вёрстка и поведение на 200–400% zoom
требуют отдельного приёмочного прогона; здесь они не объявлены проверенными.
## 4. Целевая сетка, изменение ширины и фильтры
Это согласованное направление корректировки, а не уже существующие функции.
1. Шапка, навигация, main и footer принадлежат одной оболочке. Поисковый
и лабораторный контент не вставляются друг в друга и не дублируют footer.
2. На широком desktop три колонки с двумя доступными разделителями.
Базовый ориентир при 1440: **280 / 408 / 736 px + два захвата по 8 px**.
При 1280: **256 / 384 / 624 px + два захвата по 8 px**. Это размеры
целевых макетов, а не требование сжимать эти колонки ниже удобных минимумов.
3. Предлагаемые пределы: фильтры 256–360 px, выдача от 360 px,
документ от 560 px. Если окно не вмещает минимумы, фильтры уходят
в отдельную панель, затем документ становится отдельным представлением.
Пользовательское растягивание не может сломать соседнюю колонку.
4. Разделители: узнаваемый захват, `col-resize`, роль `separator`,
доступное имя, текущая/минимальная/максимальная ширина. Перетаскивание
и стрелки клавиатуры; отдельные «Скрыть фильтры» / «Показать фильтры»
и «Сбросить ширину». Двойной клик может дополнять, но не заменяет
видимый и клавиатурный способ сброса. Положение хранится локально;
при восстановлении ограничивается фактическим viewport.
5. На desktop у фильтров нет отдельной постоянно прокручиваемой узкой
колонки: краткие группы и раскрытие длинных справочников. Полный
каталог доступен через поиск/раскрытие; нельзя скрыть значения навсегда.
Одиночный выбор API не выдаётся за множественный checkbox.
6. Длинные названия органов открываются в панели выбора с полными строками;
основное поле не становится бессмысленной обрезанной подписью.
7. Даты имеют постоянные «От» / «До»; если два поля не помещаются,
ставятся друг под другом. Перенос текста внутри input недопустим.
8. На desktop самостоятельная вертикальная прокрутка выдачи и длинного
документа допустима. Их заголовки/toolbar доступны при чтении.
Фильтры и всё приложение не должны требовать горизонтальной прокрутки.
На телефоне — одна основная вертикальная прокрутка и отдельные панели.
9. Footer внизу короткой страницы через layout с `min-height: 100dvh`
и растущим main, а не `position: fixed` поверх содержимого. При длинном
контенте footer следует после него. Панель сохранения имеет собственное
место; sticky допускается только без перекрытия последней строки.
## 5. Единые правила всех окон и форм
- Видимый короткий заголовок и крестик в правом верхнем углу, область 44×44.
Закрытие не спрятано в конце длинного содержимого.
- Desktop: ширина по задаче, обычно 480–640 px, максимум относительно
viewport; mobile: отступ 16 px либо полноэкранная форма для длинного ввода.
Заголовок/закрытие остаются доступны при прокрутке содержимого.
- `dialog` / `aria-modal`, имя через заголовок; фокус при открытии внутри,
Tab/Shift+Tab не уходят в фон, Escape закрывает обычное окно, фокус
возвращается на инициатор. Фон не интерактивен. Исключения для
обязательной смены пароля объясняются пользователю, не маскируются.
- Не наслаивать кабинет и пользователей. Переход между задачами — замена
поверхности с понятным возвратом. Подтверждение удаления — короткое
целевое окно, а не второй длинный редактор за первым.
- «Профиль» — просмотр сведений; «Безопасность» — смена пароля. Доступ
к администрированию определяется сервером, редизайн не меняет права.
- В форме пароля постоянные подписи, подсказка требований, показать/скрыть
пароль с доступным именем; подтверждение нового пароля как защита от
опечатки. Никакие реальные пароли не предзаполняются и не записываются
в материалы аудита. Изменение пароля выполняет человек.
- Основное действие одно; «Отмена» вторична; «Выйти» отдельно от сохранения.
Enter отправляет только активную форму. Ошибки рядом с полем и в
доступном сообщении, введённые допустимые данные сохраняются.
- При несохранённом редактировании — понятная защита от случайной потери;
обычное закрытие окна просмотра не требует лишнего подтверждения.
- Loading исключает повторную отправку; при успехе сообщается результат;
при ошибке есть путь восстановления. Валидация не ограничивается цветом.
- Эти правила распространяются на кабинет, вход, обязательную смену пароля,
источники, создание/сброс/удаление пользователя, обучение и документный viewer.
Основания для доступного поведения:
[WAI-ARIA Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/),
[Window Splitter](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
(паттерн APG находится в разработке; использовать как ориентир),
[WCAG Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html).
Крестик и структура кабинета — также прямое требование пользователя.
## 6. Реализация и проверяемое покрытие
Кодовая реализация сгруппирована по общей причине, а не по числу замечаний.
Ниже перечислены все UI-01—UI-33, привязанные к коду и регрессионному
сценарию. Playwright fixture использует только условные записи `admin`,
`fixture-user` и `fixture-created`; browser requests полностью перехватываются
тестом и не обращаются к приложению, Gitea или production API. Локальный browser
run завершился успешно.
| Пункты | Изменение | Регрессионное покрытие |
|---|---|---|
| UI-01—02 | Разделены initial/loading/results/empty/error; панель документа показывает инструменты после выбора редакции | Пустой результат, API ошибка и редакция без текста |
| UI-03—08 | Скомпонованы фильтры и сворачиваемые группы, читаемые счётчики и языковые select; навигация адаптируется на промежуточной ширине | Reflow 320/390/640/1024/1280/1440 CSS px; на 640 px проверены кабинет, выход и создание пользователя на `/`, `/review`, `/users` |
| UI-09—10 | Кабинет подписан на desktop; экран переходит от трёх колонок к адаптивной компоновке | Геометрия действий и отсутствие page overflow на 640 px; layout измерен на 1024/1280/1440 px |
| UI-11—16 | Breadcrumb не дублирует заголовок; toolbar переносится; широкие таблицы имеют собственный scroll; URL-атрибуты очищаются; разворот остаётся в панели документа | Fixture удаляет `srcset`, `ping`, private-IP `href/src`, относительные и HTTP ссылки, сохраняя текст и официальную HTTPS-ссылку; лаборатория проверяет A → ошибка B → успешная B и устаревший ответ A |
| UI-17 | Переведены подписи поиска, фильтров, кабинета и `/users`; язык документа остаётся независимым | Переключение RU/KY и язык редакции; полный ручной лингвистический review остаётся открытым |
| UI-18—20 | Убрано дублирование footer, согласованы контейнеры, лаборатория ограничена по высоте документа | Fixture проверяет один видимый footer; панель 612 px при viewport 900 px и scrollHeight 41027 px |
| UI-21—24 | Кабинет имеет имя и разделы профиля/безопасности; переход к пользователям закрывает кабинет и открывает отдельный маршрут | ARIA имя, Escape, focus trap/return и отдельные маршруты |
| UI-25—28 | Управление пользователями — отдельный экран; создание и удаление — отдельные окна; сброс и история раскрываются по запросу; общие крестики | Fixture выполнил смену пароля, создание, сброс пароля и удаление только `fixture-created`; production учётные записи и API не использовались |
| UI-29 | Обучение запускается пользователем, без автоматического перехвата фокуса | Регрессионный сценарий обучения |
| UI-30—31 | Оценка 2×2, комментарий скрываемый; сохранение выключено до выбора результата, есть состояние черновика | Fixture проверяет черновик и фиктивное сохранение; рейтинг и комментарий сохраняются при переключении документа, включая ошибку B и устаревший ответ A |
| UI-32—33 | Footer содержит продукт и версии; документ лаборатории имеет собственную прокрутку до панели сохранения | Версии в обоих footer; длинный текст прокручивается внутри панели, сохранение и footer остаются ниже неё |
Локальный Playwright run прошёл на **320×800, 390×844, 640×450, 1024×900,
1280×800 и 1440×900 CSS px**; горизонтального переполнения документа нет.
При 640 CSS px кабинет, выход и создание пользователя остаются видимыми и
помещаются в viewport на трёх маршрутах. Это проверка reflow, не проверка
browser zoom: в доступном headless Chromium Ctrl+Plus не меняет CSS viewport;
реальный zoom 200–400% остаётся непроверенным.
При 1280 px сетка измерена как 256 / 384 / 640 px. Для длинной лабораторной
редакции панель ограничена 544 px при viewport 800 px, scrollHeight 27278 px;
сохранение и footer остаются ниже неё в DOM и доступны независимо от текста.
Снимки, overlay и pixel-difference с целевым изображением сохранены в
[каталоге visual review](visual-review/). Растровый diff является обзорной
диагностикой, а не пороговой проверкой: макет и fixture-контент различаются.
Ручной лингвистический review всех скрытых KY-подписей, физический touch,
200–400% zoom и операции с реальными пользователями/паролями не выполнялись.
Не мигрировать существующий Python frontend на React/Next.js ради этих
исправлений. Не менять ранжирование, корпус или права доступа в UI-задаче.
## 9. Follow-up замечания владельца: поиск, кабинет и лаборатория
Повторный просмотр трёх экранов выявил отдельные проблемы уже после PR #54.
Исправления остаются в этом плане, чтобы не создавать параллельный список:
| Поверхность | Наблюдение | Реализация и проверка |
|---|---|---|
| Поиск: выдача и чтение | Метка «Открыт» ставилась до ответа, могла оставаться после ошибки и дублироваться; оглавление пропускало структурные заголовки из жирных `p > b > span`; выбор языка был обрезан и расположен в навигации; в фильтрах оставался декоративный SVG с пустым резервом; активный пункт «Поиск» визуально смещался | Метка очищается при смене результата/редакции и появляется только после успешной загрузки; оглавление распознаёт самостоятельные заголовки раздела/главы/статьи на RU/KY, назначает уникальные якоря и строит вложенные списки; язык поиска находится в фильтрах с полной шириной; SVG и его место удалены; ссылки навигации используют палитру Акылдаш. Проверка: ошибки и гонки загрузки, заголовки RU/KY и обычные упоминания, уникальные якоря, диапазон и доступность splitter |
| Личный кабинет | Навигация выглядела ссылкой браузера; закрытие занимало строку текстом; подписи и значения профиля стояли в столбик; заголовок был крупным; появлялось горизонтальное переполнение; действие управления пользователями было неочевидным | Ссылки кабинета оформлены как действия с сохранением семантики `<a>`; крестик остаётся SVG-кнопкой 44×44 с доступной подписью; профиль выровнен в пары «поле — значение»; заголовок компактнее; длинные значения переносятся; администратор получает явную ссылку «Управление пользователями». Проверка: RU/KY, скрытие ссылки для не-администратора, фокус/закрытие диалога и ширина содержимого |
| Лаборатория | Дублировалась роль рядом с именем; ссылки навигации не имели вида кнопок; оболочка, форма, рабочая область, сохранение и footer имели разные границы; пустое сообщение резервировало высоту; панели выглядели разрозненными карточками; основной заголовок переносился; мобильный экран наследовал искусственную высоту | В шапке показывается только имя, роль остаётся в профиле; навигация сохраняет ссылки и получает единый вид кнопок; все участки выровнены по общему shell; пустой live status не добавляет зазор; результаты и документ образуют общую поверхность с внутренней прокруткой на desktop; заголовок использует доступную ширину и размеры по макету Figma; mobile возвращается к естественному потоку с прокруткой документа. Проверка: desktop/mobile, пустой status, сохранение после длинного документа и отсутствие page overflow |
Frontend повышен до **0.3.0** из-за добавления доступного изменения ширины
панелей. Backend и его API не менялись. Fixture данные искусственные; preview
слушает только loopback, запрещает запросы за пределы своего origin и не
использует авторизационные cookies или production API. Браузерный прогон прошёл
на 24 октября 2026 года; пять скриншотов сохранены в `current-progress` рядом
с отчётом assignment. CI и независимое ревью проверяются для конкретного pushed
SHA.
## 7. Приёмка: что значит «вылизанный интерфейс»
| Проверка | Условие |
|---|---|
| Оболочка | Один видимый header и footer на каждом маршруте, единые границы блоков |
| Фильтры | Нет горизонтального скролла; полный текст действий, читаемые подписи и счётчики |
| Начальное состояние | Не сообщает об отсутствии результатов до первого поиска |
| Документ | Нет пустых селектов и активных бесполезных действий до выбора |
| Колонки | Drag и клавиатура работают; min/max соблюдаются; скрытие/возврат и сброс сохраняют контекст |
| Телефон | Основные действия видимы без горизонтального скролла, есть возврат из документа/фильтров |
| Окна | Заголовок, крестик, Escape, trap/return focus; нет невидимого окна поверх целевого |
| Лаборатория | Поиск → чтение → оценка → черновик → сохранение, панель до footer, данные не теряются |
| Локализация | RU/KY-подписи, Ө ө Ү ү Ң ң, язык UI не подменяет язык документа |
| Типографика | Реально загрузились Noto, основной текст 17–18 px / 1.6–1.7, длинные названия читаемы |
| Таблицы | Собственная обозначенная прокрутка, основной текст и controls без обрезки |
| Доступность | Контраст WCAG AA, видимый фокус, Tab/Shift+Tab/Enter/Escape, zoom/reflow, reduced motion |
| Авторизация | Проверены вход, неверный пароль, истечение сессии и обязательная смена в безопасном тестовом окружении |
| Снимки | 1440×900, 1280×800, 1024×768, 390×844; дополнительно 320 CSS px для reflow |
Для визуального сравнения использовать изолированный детерминированный fixture
и одинаковые состояния, а не изменчивую выдачу. Fixture не включается в
production-путь и не обходит авторизацию. Сравнивать отдельно шапку,
фильтры, выдачу, документ, окна, сохранение и footer. Сглаживание текста
не требует нулевого pixel diff; совпадение растра не оправдывает обрезку,
неработающий control или нечитаемый реальный контент.
Локальные проверки, указанные в PR, включая Playwright, прошли. Changelog
обновлён в этой ветке. Дальше остаются CI и ручное PR review по обычному
процессу репозитория; автоматический reviewer не запускается. Production не
меняется автоматически.
## 8. Материалы и оставшаяся проверка
Figma содержит целевые редактируемые состояния, а не исправленный live-сайт
и не доказательство работоспособности функций. Учётные записи в ней
обезличены, текст документа помечен как пример оформления.
| Figma-экран | Ссылка |
|---|---|
| 01. Поиск с документом, 1440×900 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-2) |
| 02. Фильтры скрыты | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-173) |
| 03. Начальный поиск | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-288) |
| 04. Лаборатория и доступное сохранение | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=3-395) |
| 05. Кабинет: профиль и закрытие | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-30) |
| 06. Кабинет: безопасность | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-229) |
| 07. Источники | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-428) |
| 08. Пользователи: отдельный экран | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-605) |
| 09–11. Телефон: выдача, чтение, фильтры | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=5-670) |
| 12. Эталон 1280×800 | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-74) |
| 13. Планшет: две панели | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-245) |
| 14. Кабинет на телефоне | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=7-360) |
| 15. Вход, создание и подтверждение удаления | [Открыть](https://www.figma.com/design/3PE98PJYOXo7a35u2X5VqB?node-id=8-118) |
Макеты построены редактируемыми текстами, Auto Layout, SVG, локальными
переменными цветов/отступов и экземплярами общих кнопок; целый интерфейс
не вставлен одной картинкой. Прокрутка длинной выдачи/текста в Figma
обозначает намеренное продолжение содержимого. Drag-разделители,
закрытие и формы — спецификация будущего поведения, не работающий web-код.
Локальные экспорты целевых экранов для просмотра без Figma:
[поиск](../design/tunduk-search-target.png),
[лаборатория](../design/tunduk-review-target.png),
[кабинет](../design/tunduk-account-target.png).
Контрольные свидетельства live-сайта:
[поиск с кодексом](../design/audit-2026-10-23/search-document-1440.png),
[мобильный поиск](../design/audit-2026-10-23/search-mobile-390.png),
[фильтры телефона](../design/audit-2026-10-23/filters-mobile-390.png),
[кабинет](../design/audit-2026-10-23/account-desktop.png),
[кабинет телефона](../design/audit-2026-10-23/account-mobile-390.png),
[лабораторный документ](../design/audit-2026-10-23/review-document-1440.png).
Browser evidence текущей ветки:
[desktop 1280×800](visual-review/desktop-1280x800.png),
[mobile 390 px](visual-review/mobile-390px.png),
[Figma overlay](visual-review/overlay-50.png) и
[pixel difference](visual-review/pixel-difference.png). Overlay показывает
различия компоновки между live-target frame и deterministic fixture; pixel
diff не используется как pass/fail.
Регрессия UI10: сетка поиска остаётся двухпанельной на ширине 1101 и 1175 px;
третья панель включается при 1176 px, когда минимальные ширины трёх колонок
помещаются. Проверка также меняет ширину окна при скрытых фильтрах и проверяет
отсутствие горизонтального переполнения. Реальные browser zoom и экспертиза
юридической корректности по законодательству не подтверждены этими тестами.
Не проверялись реальные destructive/auth-сценарии: вход с неверным паролем,
смена пароля, удаление/создание пользователей и отправка оценки в production.
Наличие макета не считается доказательством этих функций.
@@ -0,0 +1,318 @@
# Диагностика релевантности поиска — 3 ноября 2026 года
## Решения и границы
Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление
терминологии, условный multi-index scoring и контракт для закрытия пробела
корпуса. Выдачи и AI-аннотации не являются юридической оценкой. Целевой A/B
прогон и полный benchmark для итогового SHA описаны ниже; они не заменяют
экспертную оценку.
Изменён backend `0.13.3` → `0.13.4`, frontend внутренней лаборатории
`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7`
относится к предшествующему составу ranking semantics; новые подписанные
снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются.
Финальный runtime/batch прогон кода выполнен на `1c7ddf2`; последующий коммит
этого отчёта и changelog меняет только документацию.
## Исходный benchmark
Зафиксированный набор содержит 7 500 русских формулировок: 15 тематических
групп, по 500 запросов в каждой. Это 750 тем с десятью вариантами формулировки,
а не 7 500 независимых правовых ситуаций. Исходный индекс
`akyldash-fragments-v2` содержал около 42,6 млн фрагментов и занимал 11,2 ГБ.
| Показатель Акылдаш, исходный прогон | Результат |
|---|---:|
| Собранные выдачи | 7 500 / 7 500 |
| Пустые выдачи | 6 |
| Медиана / P95 ответа | 21,67 / 77,36 мс |
| Совпадение основ темы в видимых названии/фрагменте, top 1 | 40,57% |
| То же, top 3 / top 10 | 47,05% / 51,40% |
Сравнение с КонсультантПлюс в исходном отчёте — только диагностическое:
юрисдикции и корпуса различаются. Совпадение основ темы не измеряет Recall,
MRR, полноту ответа или юридическую правильность; российская выдача не является
эталоном права Кыргызстана. Для вывода о качестве нужны экспертные релевантные
коды, контрольный набор и отдельные метрики.
## Подтверждённые классы ошибок
14 коротких документов прочитаны целиком; Codex (ИИ) подготовил по ним 79
предварительных оценок пар «запрос—документ» в 71 сессии: 59 помечены как
нерелевантные и 20 как косвенно полезные. Это тематическая AI-разметка для
триажа, а не заключение юриста или независимая экспертная проверка. Она не
подтверждает полноту правового ответа. Неоценённые результаты не считались
ошибками.
| Тема | Наблюдавшийся неверный приоритет | Вывод |
|---|---:|---|
| Расторжение договора аренды | 135066 | Решение по одной сделке с землёй не даёт общих оснований расторжения |
| Международный коммерческий арбитраж | 26411 | Продажа земельных участков не регулирует арбитраж и отказ в признании решения |
| Испытательный срок работника | 94603 | Земельный участок под завод/испытательный полигон совпадает словами, но не предметом |
| Место жительства ребёнка | 50760 | Финансирование приюта не устанавливает порядок спора родителей |
| Перевод студента в другой вуз | 14165 | Разовое финансирование студентов не устанавливает процедуру перевода |
| Таможенные пошлины | 41854 | Индивидуальная отсрочка одному предприятию не задаёт общие правила |
| Общий порядок предоставления земли | 54735, 54571 | Решения по отдельным участкам не являются общей процедурой |
| Защита критической инфраструктуры | 59082 | Предоставление земли под завод не устанавливает защитные требования |
| Купля-продажа / расторжение договора | 60053 | Расторжение одной сделки с госакциями не является общей нормой |
| Подача искового заявления | 18516, 7115 | Отдельный спор и узкая льгота по пошлине не дают общего порядка подачи иска |
| Принятие нормативных актов | 77190 | Первое чтение одного законопроекта не устанавливает общий процесс |
| Подключение к водоснабжению | 135061 | Целевая помощь отдельным домохозяйствам не задаёт технические условия |
Проверенные варианты глобального повышения веса кодексов отвергнуты: вес 2 не
исправил аренду и подачу иска, а вес 4 поднял Налоговый кодекс выше профильных
источников по воинской обязанности и подаче иска. Общие веса или жёсткие
приоритеты кодексов не добавлялись.
## Узкая терминологическая поправка
В исследованной редакции ГК КР II (код 5, редакция 460) глава 27 и статья 542
связывают договор аренды с имущественным наймом; статьи 558–559 используют
термин «имущественный наем». В полном тексте этой редакции буквальная фраза
«расторжение договора аренды» не встречалась. Исходный запрос возвращал код
135066 на первом месте, а формулировка «расторжение договора имущественного
найма» — код 5.
Backend теперь сохраняет исходную строку и добавляет только для точного
сочетания «договор [арендная форма]» вариант «договор [та же форма]
имущественного найма». В частности, `договора аренды` превращается в
`договора имущественного найма`. Расширение не срабатывает для аренды квартиры
или земельного участка, поднайма, найма работника или лизинга. Точный запрос
остаётся в строгой лексической ветке, поэтому его профильный муниципальный акт
не исключается из поиска.
В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся
с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066
оставался первым. На SHA `726b1ee` isolated code-only прогон на исходном индексе
поставил код 5 первым для общего запроса; из 7 500 запросов изменились 19
выдач, 7 481 совпала с baseline, число пустых осталось 6. Этот результат
проверяет `search-7` с `match_phrase` boost 12 для синтетической юридической
фразы, исходный phrase boost 3; он не описывает multi-index DFS. После
подключения трёх актов в отдельном supplement на `e653f3b` изменились 184
выдачи против code-only варианта и 38 top-1; все шесть пустых выдач
сохранились. Разные наборы индексов нельзя считать одним benchmark. Итоговый
conditional-DFS прогон на `1c7ddf2` приведён ниже. Контроль запросов об аренде
жилья/земли, поднайме, трудовых отношениях, международном праве и KY
формулировках выполнялся отдельно; rank-метрики не являются юридической
оценкой.
## Условный scoring для явного multi-index scope
При поиске по явно настроенному comma-separated scope (`base,supplement`) API
передаёт `search_type=dfs_query_then_fetch` как URL query parameter. Запросы по
одному настроенному индексу или alias сохраняют прежний `query_then_fetch`;
одиночные wildcard и alias не классифицируются по результату их expansion.
DFS включается только при запятой в настроенной строке scope.
Тело DSL, фильтры, query weights, сортировка, индексы и документы не меняются.
Параметр применяется только к `/search`; фильтры и остальные API вызовы
используют прежний режим.
OpenSearch описывает `query_then_fetch` как scoring по локальным term/document
frequencies шардов, а DFS — по глобальным frequencies через шарды; DFS обычно
точнее, но может быть медленнее. `search_type` поддерживается именно как
параметр Search API URL ([OpenSearch Search API](https://docs.opensearch.org/latest/api-reference/search-apis/search/)).
Поэтому ограниченный режим использует глобальные statistics для явной
multi-index конфигурации и не меняет behavior одиночного поиска.
На изолированном OpenSearch 3.7.0 оба режима сравнили на одинаковых DSL bytes и
одном неизменном наборе данных: 300 заранее выбранных запросов, максимум 300.
Порядок менялся в 93 случаях, top-1 — в 29; из 9 именованных риск-кейсов
изменились 3, все 5 выбранных positive controls сохранили top-1. Контроль с
индивидуальным актом 135066 сохранил этот акт первым. В пяти запросах о
педагогических/научных работниках и одном запросе о болезнях животных top-1 не
изменился. Эти шесть случаев требуют предметной оценки и не объявляются
автоматически нерелевантными: статья 150(8) закона об охране здоровья связывает
аттестацию педагогических/научных работников с медицинской деятельностью, а
случай с болезнями животных остаётся без экспертной оценки. Новые оценки пар не
выставлялись; специальные веса или document IDs не добавлялись. Сравнение
ограничено выборкой и не является разметкой юридической правильности.
Медианы 22,955 мс для default и 16,115 мс для DFS не используются как оценка
скорости: default всегда исполнялся первым, возможен cache/order bias.
Frozen experiment metadata is stored outside the repository in
`reports/search-benchmark-2026-11-02/dfs-probe-e653-summary.json`. The paired
response-capture artifact `dfs-probe-e653.jsonl` has SHA-256
`4a9373ba5236c5c5a1d85139b6dbe67bcf01df02216f8b2f292e5c451e6432c8`.
Выборка содержала base index (42 624 501 fragments) и read-only supplement
(8 934 fragments); UUID/count guards прошли, основной индекс и сохранённые
отзывы во время эксперимента не менялись. В PR не включены сырые ответы,
персональные данные или выдачи документов.
Кодовый тест проверяет URL, encoded multi-index path, RU/KY, пустую выдачу,
прежнее тело запроса и неизменность single alias/index и filters. Read-only
integration gate для root-owned runtime:
```bash
AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \
AKYLDASH_OPENSEARCH_INDEX='akyldash-fragments-v2,akyldash-benchmark-supplement-20261103-r1' \
PYTHONPATH=backend python3 -m unittest \
backend.test_search_api_opensearch.SearchMultiIndexDfsOpenSearchTest.test_global_statistics_mode_accepts_read_only_multi_index_search -v
```
Root выполнил этот read-only gate на 8081 для `1c7ddf2`: фактический multi-index
URI использовал DFS и подписанный token сохранял полный scope. Gate выполняет
только поиск и не создаёт/удаляет индексы. Полный benchmark на том же code SHA
и frozen baseline приведён ниже; 300-query experiment и более ранние прогоны
остаются отдельными сравнительными срезами.
Откат к предшествующему приложению возвращает `query_then_fetch` для multi-index
поиска. Индекс, документы, saved reviews и старые подписанные токены при этом
не требуют изменений или миграции; новые отзывы, сохранённые до rollback,
остаются помечены `search-8`. Для supplement действует отдельный rollback
контракт ниже: отключение его подключения скрывает только добавленные редакции,
не удаляя подготовленные файлы и базовый индекс.
## Итоговый pinned прогон search-8
Полный isolated прогон для executable SHA `1c7ddf2fcbd7c55920470e7ac52339fdab0366db`
собрал 7 500 уникальных запросов с версией `search-8`. SHA-256 выдачи:
`fc02e22e5b3d612813ffc0bdaf9892bb16ee4c29af3e1b77f79e58a0a45c8ec1`. Итог
сопоставлен с frozen baseline и двумя явно указанными промежуточными режимами:
| Сравнение | Изменён порядок | Изменён top-1 |
|---|---:|---:|
| Исходный baseline `search-6` | 537 | 103 |
| Code-only `726b1ee`, `search-7` | 521 | 93 |
| Code + три акта, `e653f3b`, без DFS | 425 | 63 |
Это число изменений порядка, а не улучшений. Все шесть пустых выдач сохранились
во всех четырёх прогонах. Среди ранее проверенных Codex (ИИ) пар с нулевой
оценкой top-1 стало 26 вместо 28 в baseline; остальные 26 не объявлены
исправленными, и новые выдачи автоматически не оценивались.
| Добавленный акт | Наблюдался в top-1 / top-3 / top-10 |
|---|---:|
| Трудовой кодекс КР `230021083` | 24 / 58 / 105 |
| Земельный кодекс КР `230029465` | 4 / 11 / 26 |
| Закон об охране здоровья `230000918` | 34 / 51 / 92 |
Все шесть точных названий новых актов на RU/KY дали ожидаемый текущий акт первым.
Все 284 запроса, общие с целевым DFS экспериментом, совпали с ним по полному
порядку кодов и редакций. Проверки dataset guard подтвердили неизменность
исходного индекса, manifest, mappings и UUID/count, а также 115 подключённых
файлов. 7 503 записи локальной лаборатории совпали с backup; обе SQLite
integrity checks прошли. Записи оценок, комментарии и пользователи не менялись.
Шесть межтематических контекстов нельзя автоматически считать нерелевантными:
медицинский закон остался первым в пяти запросах о педагогических/научных
работниках и в одном запросе о болезнях животных. Для педагогических случаев
проверенная статья 150(8) связывает аттестацию этих работников с медицинской
деятельностью; новые оценки пар не выставлялись. Вопрос о животных также
остался без экспертной оценки. ИИ-аннотации не доказывают полноту правового
ответа или юридическую правильность ранжирования.
Режим и scope дополнительно проверены на runtime `8081`: для multi-index `/search`
уходит DFS, для одиночного индекса и filters — прежний default; тело DSL
одинаково. Alias проверен transport mock без создания alias. Runtime mode/API
smoke включал HMAC/TTL, signed scope, сохранение пустого результата и
отрицательные rank/code сценарии. Browser UI fixture прошёл на `726b1ee`; UI
побайтно не менялся в следующих SHA, но отдельный browser прогон на `1c7ddf2`
не выполнялся. Медиана backend `23.685 мс` описательна и не является SLO или
доказательством ускорения.
Эксперимент подключал только три акта в отдельном supplement и не доказывает
полноту всего корпуса, актуальность всех редакций, наличие вложений или
production-готовность. Отключение overlay/supplement скроет эти новые редакции
из локального поиска/API, но сохранит подготовленные файлы, review-записи и
исходный индекс. Production не менялся. Подробный технический отчёт и raw
benchmark остаются во внешнем рабочем наборе и в PR не включены.
## Пустые выдачи в лаборатории
Исходный контракт блокировал сохранение шести подписанных пустых снимков:
API отвергал `results: []`, а интерфейс читал поля из пустого placeholder.
Теперь signed snapshot может сохранить пустую оценку с обязательным общим
комментарием. В записи `top_result_code` остаётся `null`, список результатов
пустой. Непустой снимок по-прежнему обязан получить оценку каждого ранга ровно
один раз, совпадающую с подписанным кодом документа. HMAC, TTL и текущая
аутентификация не меняются.
Подписанный снимок теперь сохраняет scope запроса при явно настроенном
multi-index выражении (`A,B`): provenance не сужается до индекса, который
случайно вернул единственный результат, и сохраняется при пустой выдаче. Для
одного настроенного индекса или alias остаётся прежнее поведение: единственный
concrete `_index` записывается как provenance; если OpenSearch не сообщил его,
сохраняется настроенный scope. Новые отзывы получают эту метку при сохранении.
SQLite-схема, исторические записи и формат подписанных токенов не меняются.
Откат к старому коду не требует миграции и не переписывает существующие
отзывы; пока работает откатанная версия, новые multi-index отзывы снова могут
получить суженную метку, поэтому исправление provenance следует вернуть вместе
с основным выпуском.
Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых
сессии, 79 AI-оценок и 55 516 диагностических комментариев; 74 715
оценок результатов остались пустыми намеренно. Integrity check прошёл,
порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не
переписывались. В последующем изолированном trial шесть реальных подписанных
пустых отзывов с общими комментариями были сохранены как записи 7498–7503;
итоговые 7 503 строки лаборатории совпали с backup, integrity check прошёл.
Пустые query-level отзывы не являются оценками отдельных документов.
## Пробел актуального корпуса и безопасный контракт закрытия
Отдельно от ранжирования, в исходном base index не было трёх
подтверждённых действующих актов (все три canonical ID имели `_count = 0`):
| Акт | Canonical ID | Последняя проверенная редакция |
|---|---:|---|
| Трудовой кодекс КР от 23.01.2025 № 23 | 230021083 | 57548 от 28.07.2026 |
| Земельный кодекс КР от 18.07.2025 № 149 | 230029465 | 34746 от 18.07.2025 |
| Об охране здоровья граждан КР от 12.01.2024 № 14 | 230000918 | 47553 от 23.01.2026 |
Это разрыв между нормализованными документами и индексом, а не полное
отсутствие актов в локальном архиве: в `data/minjust-normalized` уже есть
canonical-каталоги всех трёх документов, manifest сообщает 209 958 успешно
обработанных документов. Однако трудовой акт там отстаёт от официальной
редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026);
земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье
последняя подготовленная — № 60 от 23.01.2026. В base index эти три canonical
ID не попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать
или поднять как замену актуальным нормам.
Для локальной изолированной проверки подготовлены точные официальные RU/KY
редакции: 3 документа и 8 934 фрагмента. На runtime 8081 они были временно
подключены отдельным supplement-index и доступны API через read-only overlay
catalogues. Это не изменение base index или production и не доказательство
полноты корпуса. Вложения и полная история новых canonical ID отдельно не
подтверждены.
Закрытие пробела требует отдельного контролируемого обновления данных:
1. Заново сверить официальные canonical ID, статус, edition ID и дату; сохранить
исходный ответ, хеши и provenance в отдельном staging-каталоге.
2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и
вложения; не импортировать метаданные с ненужными персональными полями.
3. Нормализовать документы отдельно и проверить exact-count/hash. Для
ограниченного дополнения собрать отдельный версионный индекс только из этих
актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов.
4. Изолированный read-only trial уже проверил отдельный supplement-index,
подключённые каталоги `data_root`, RU/KY выдачу, current-edition routes,
collapse и контрольные запросы. Перед новым подключением нужно повторно
сверить official source и dataset guards; trial не записывал новые документы
в base index и не менял alias.
5. Публикация требует отдельного владельческого разрешения и проверенного
rollback. При отключении локального supplement-index поиск и просмотр новых
редакций из него прекращаются; подготовленные файлы и уже сохранённые отзывы
сохраняются, базовый индекс не удаляется. PR
фиксирует код и результаты локального испытания; production, base index и
исходные данные не менялись.
## Что остаётся проверить
- Финальный code/data эксперимент и full 7 500 run относятся к executable SHA
`1c7ddf2`; текущий follow-up commit меняет только этот report и changelog.
CI и полное независимое ревью должны повторно пройти на новом PR head.
- Browser fixture пустой выдачи прошёл на `726b1ee`; UI побайтно неизменён в
следующих commit. Для `1c7ddf2` выполнены реальные API/runtime smokes, но новый
полный browser run не заявляется.
- Три supplement-акта не подтверждают полноту всего корпуса, полную историю,
вложения, лицензирование или production readiness. Для новой публикации нужен
свежий source/edition check и отдельное разрешение владельца.
- Измерения top-k, AI-разметка и технические smoke checks не заменяют независимую
экспертную оценку юридической релевантности.
Исходные измерения и хеши хранятся вне репозитория в рабочем наборе
`search-benchmark-2026-11-02`. В этот репозиторий не включаются сырые ответы
MinJust, диагностические логи, внешние пользовательские данные или выгрузка
лабораторных комментариев.
+150 -15
View File
@@ -4,6 +4,7 @@
from __future__ import annotations
import argparse
import datetime
import json
import os
import re
@@ -18,20 +19,23 @@ from pathlib import Path
from archive import verify_external_archive
from external_session import (ArchiveFrameWriter, read_challenge, read_response, read_result,
MAX_ARCHIVE_BYTES, receive_archive, write_ack, write_request)
MAX_ARCHIVE_BYTES, receive_archive, validate_request, write_ack, write_request)
DEFAULT_REPOSITORY = Path(r"D:\Dev\akyldash")
DEFAULT_DEPLOY_SSH_ALIAS = "akyldash_release"
TRIGGER_CONFIG = Path(r"C:\ProgramData\Akyldash\release-trigger.json")
AUTO_APPLY_CONFIG = Path(r"C:\ProgramData\Akyldash\release-auto-apply.json")
PREPARED_STATE_FILE = Path(r"C:\ProgramData\Akyldash\prepared-release.json")
RELEASE_STATUS_COMMAND = "/usr/local/sbin/akyldash-release-status"
MAX_STATUS_BYTES = 1024
STATUS_TIMEOUT_SECONDS = 180
NOT_READY_REASONS = {
"recovery_journal", "host_checkout_stale", "no_current_main_release", "main_advanced",
"already_applied",
"recovery_journal", "runtime_config_changed", "release_policy_changed",
"no_current_main_release", "main_advanced", "already_applied",
}
MAX_ERROR_BYTES = 8192
_WINDOWS = os.name == "nt"
DEFAULT_SSH = str(Path(r"C:\Windows\System32\OpenSSH\ssh.exe")) if _WINDOWS else "ssh"
def _reparse(path: Path) -> bool:
@@ -79,8 +83,9 @@ $allowed = @($owner, 'S-1-5-18', 'S-1-5-32-544')
$aclOwner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
if ($aclOwner -notin $allowed) { exit 6 }
$ownerAllowed = $false
foreach ($rule in $acl.Access) {
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
$rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
foreach ($rule in $rules) {
$sid = $rule.IdentityReference.Value
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $sid -notin $allowed) { exit 2 }
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 3 }
if ($sid -eq $owner) { $ownerAllowed = $true }
@@ -213,7 +218,7 @@ def validate_status_response(payload: bytes) -> dict | None:
or value["release"] is not None):
raise ValueError("release status not-ready response is invalid")
return None
if value["state"] != "candidate" or value["reason"] is not None:
if value["state"] != "prepared" or value["reason"] is not None:
raise ValueError("release status state is invalid")
release = value["release"]
if not isinstance(release, dict) or set(release) != {"run_id", "commit", "descriptor_sha256"}:
@@ -223,7 +228,59 @@ def validate_status_response(payload: bytes) -> dict | None:
or not isinstance(release["descriptor_sha256"], str)
or not re.fullmatch(r"[0-9a-f]{64}", release["descriptor_sha256"])):
raise ValueError("release status candidate identity is invalid")
return {"version": 1, "action": "release", **release}
request = {"version": 1, "action": "release", **release}
validate_request(request)
return request
def write_prepared_request(request: dict, path: Path = PREPARED_STATE_FILE) -> None:
validate_request(request)
if _reparse(path.parent) or not path.parent.is_dir() or _reparse(path):
raise ValueError("prepared release state path is missing or unsafe")
check_private_acl(path.parent)
if path.exists():
check_private_acl(path)
value = {
"schema_version": 1,
"prepared_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
}
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp")
try:
with temporary.open("xb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
check_private_acl(temporary)
os.replace(temporary, path)
check_private_acl(path)
finally:
temporary.unlink(missing_ok=True)
def read_prepared_request(path: Path = PREPARED_STATE_FILE) -> dict:
if _reparse(path.parent) or _reparse(path) or not path.parent.is_dir() or not path.is_file():
raise ValueError("prepared release state is missing or unsafe")
check_private_acl(path.parent)
check_private_acl(path)
raw = path.read_bytes()
if not raw or len(raw) > MAX_STATUS_BYTES:
raise ValueError("prepared release state is empty or oversized")
try:
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
except (UnicodeError, json.JSONDecodeError) as error:
raise ValueError("prepared release state is invalid JSON") from error
if (not isinstance(value, dict) or set(value) != {"schema_version", "prepared_at", "release"}
or type(value["schema_version"]) is not int or value["schema_version"] != 1
or not isinstance(value["prepared_at"], str) or not isinstance(value["release"], dict)):
raise ValueError("prepared release state has an unsupported schema")
release = value["release"]
if set(release) != {"run_id", "commit", "descriptor_sha256"}:
raise ValueError("prepared release tuple is invalid")
request = {"version": 1, "action": "release", **release}
validate_request(request)
return request
def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
@@ -255,7 +312,31 @@ def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
return value
def query_release_status(*, ssh: str = "ssh", ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
def load_auto_apply_config(path: Path = AUTO_APPLY_CONFIG) -> dict:
if not _WINDOWS:
raise ValueError("automatic release apply is available only on Windows")
if (_reparse(path) or _reparse(path.parent) or not path.is_file() or not path.parent.is_dir()):
raise ValueError("automatic release apply configuration is missing or unsafe")
check_private_acl(path.parent)
check_private_acl(path)
try:
raw = path.read_bytes()
except OSError as error:
raise ValueError("automatic release apply configuration is unreadable") from error
if not raw or len(raw) > 1024:
raise ValueError("automatic release apply configuration is empty or oversized")
try:
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
except (UnicodeError, json.JSONDecodeError) as error:
raise ValueError("automatic release apply configuration is invalid JSON") from error
if (not isinstance(value, dict) or set(value) != {"schema_version", "enabled"}
or type(value["schema_version"]) is not int or value["schema_version"] != 1
or type(value["enabled"]) is not bool):
raise ValueError("automatic release apply configuration is invalid")
return value
def query_release_status(*, ssh: str = DEFAULT_SSH, ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
if shutil.which(ssh) is None:
raise ValueError("OpenSSH client is unavailable")
if ssh_alias != DEFAULT_DEPLOY_SSH_ALIAS:
@@ -369,7 +450,7 @@ def receive_backup(reader, writer, *, repository: Path, request: dict, expected_
raise
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
ssh_alias: str = "akyldash_vps") -> None:
if shutil.which(ssh) is None:
raise ValueError("OpenSSH client is unavailable")
@@ -407,19 +488,61 @@ def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: st
raise
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, ssh: str = "ssh") -> None:
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
ssh: str = DEFAULT_SSH) -> None:
config = load_trigger_config(config_file)
if not config["enabled"]:
print("Release trigger is disabled; no SSH connection opened.")
return
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
if request is None:
print("No verified current-main release is ready; deployment not started.")
print("No verified current-main release is prepared; no deployment started.")
return
write_prepared_request(request, prepared_file)
print(f"PASS prepared release {request['commit']} (run {request['run_id']}); owner confirmation is required to deploy.")
def run_auto_apply_once(*, config_file: Path = TRIGGER_CONFIG,
auto_apply_file: Path = AUTO_APPLY_CONFIG,
prepared_file: Path = PREPARED_STATE_FILE,
ssh: str = DEFAULT_SSH) -> None:
config = load_trigger_config(config_file)
auto_config = load_auto_apply_config(auto_apply_file)
if not config["enabled"] or not auto_config["enabled"]:
print("Automatic apply is disabled; no SSH connection opened.")
return
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
if request is None:
print("No verified current-main release is prepared; no deployment started.")
return
if _reparse(prepared_file):
raise ValueError("prepared release state is unsafe")
if not prepared_file.exists():
write_prepared_request(request, prepared_file)
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
return
previous = read_prepared_request(prepared_file)
if previous != request:
write_prepared_request(request, prepared_file)
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
return
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
def apply_prepared_release(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
ssh: str = DEFAULT_SSH, confirm=input) -> None:
if not sys.stdin.isatty():
raise ValueError("applying a prepared release requires an interactive owner confirmation")
request = read_prepared_request(prepared_file)
answer = confirm(f"Type APPLY {request['commit']} to start the separately approved production release: ")
if answer != f"APPLY {request['commit']}":
print("Prepared release was not applied.")
return
config = load_trigger_config(config_file)
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
ssh_alias: str = "akyldash_vps") -> None:
if shutil.which(ssh) is None:
raise ValueError("OpenSSH client is unavailable")
@@ -470,7 +593,9 @@ def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true", help="query the fixed status endpoint and deploy at most one verified release")
parser.add_argument("--once", action="store_true", help="prepare at most one verified release; never deploy")
parser.add_argument("--apply-prepared", action="store_true", help="deploy the prepared tuple after interactive owner confirmation")
parser.add_argument("--auto-apply-prepared", action="store_true", help="apply an already prepared tuple only when the separate owner auto-apply gate is enabled")
parser.add_argument("--rollback", action="store_true")
parser.add_argument("--data-compatible", action="store_true")
parser.add_argument("--run-id")
@@ -480,11 +605,21 @@ def main() -> int:
args = parser.parse_args()
try:
if args.once:
if (args.rollback or args.data_compatible or any(
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" or any(
(args.run_id, args.commit, args.descriptor_sha256))
or args.ssh_alias != "akyldash_vps"):
):
raise ValueError("--once accepts no release tuple, rollback, compatibility flag, or custom SSH alias")
run_release_once()
elif args.apply_prepared:
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
or any((args.run_id, args.commit, args.descriptor_sha256))):
raise ValueError("--apply-prepared accepts no release tuple or rollback flags")
apply_prepared_release()
elif args.auto_apply_prepared:
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
or any((args.run_id, args.commit, args.descriptor_sha256))):
raise ValueError("--auto-apply-prepared accepts no release tuple or rollback flags")
run_auto_apply_once()
elif args.rollback:
if not args.data_compatible or any((args.run_id, args.commit, args.descriptor_sha256)):
raise ValueError("rollback requires --data-compatible and accepts no release tuple")
+87 -13
View File
@@ -203,6 +203,9 @@ class ExternalReceiverTests(unittest.TestCase):
self.assertIn("System.IO.DriveInfo", run_mock.call_args.args[0][-1])
self.assertNotIn("Get-Volume", run_mock.call_args.args[0][-1])
self.assertIn("$acl.GetOwner(", run_mock.call_args.args[0][-1])
self.assertIn("$acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])", run_mock.call_args.args[0][-1])
self.assertIn("$rule.IdentityReference.Value", run_mock.call_args.args[0][-1])
self.assertNotIn(".Translate(", run_mock.call_args.args[0][-1])
self.assertIn("$aclOwner -notin $allowed", run_mock.call_args.args[0][-1])
def test_private_acl_rejects_an_unapproved_acl_owner(self):
@@ -227,8 +230,8 @@ class ExternalReceiverTests(unittest.TestCase):
self.assertTrue(all(call.args[1].startswith("preflight-") for call in preflight.call_args_list))
popen.assert_not_called()
def test_status_response_accepts_only_the_fixed_candidate_tuple(self):
response = {"schema_version": 1, "state": "candidate", "reason": None,
def test_status_response_accepts_only_the_fixed_prepared_tuple(self):
response = {"schema_version": 1, "state": "prepared", "reason": None,
"release": {"run_id": "302", "commit": "a" * 40,
"descriptor_sha256": "b" * 64}}
self.assertEqual(receive_external.validate_status_response(json.dumps(response).encode()),
@@ -239,6 +242,10 @@ class ExternalReceiverTests(unittest.TestCase):
self.assertIsNone(receive_external.validate_status_response(
b'{"schema_version":1,"state":"not_ready","reason":"already_applied","release":null}'
))
with self.assertRaisesRegex(ValueError, "state"):
receive_external.validate_status_response(
json.dumps({**response, "state": "candidate"}).encode()
)
def test_status_response_rejects_extra_or_duplicate_fields(self):
payload = (b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",'
@@ -266,7 +273,7 @@ class ExternalReceiverTests(unittest.TestCase):
self.assertIn("ClearAllForwardings=yes", command)
self.assertEqual(popen.call_args.kwargs["stdin"], subprocess.DEVNULL)
def test_once_disabled_is_noop_and_candidate_reuses_existing_release_flow(self):
def test_once_only_prepares_and_separate_interactive_apply_requires_exact_confirmation(self):
config = {"schema_version": 1, "enabled": False,
"repository": str(receive_external.DEFAULT_REPOSITORY),
"ssh_alias": receive_external.DEFAULT_DEPLOY_SSH_ALIAS}
@@ -276,20 +283,78 @@ class ExternalReceiverTests(unittest.TestCase):
with patch.object(receive_external, "_WINDOWS", True), \
patch.object(receive_external, "check_private_acl"), \
patch.object(receive_external, "query_release_status") as query, \
patch.object(receive_external, "run_release") as deploy:
receive_external.run_release_once(config_file=path)
patch.object(receive_external, "run_release") as deploy, \
patch.object(receive_external, "PREPARED_STATE_FILE", Path(directory) / "prepared.json"):
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
query.assert_not_called()
deploy.assert_not_called()
config["enabled"] = True
path.write_text(json.dumps(config), encoding="utf-8")
candidate = {"version": 1, "action": "release", "run_id": "302",
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
query.return_value = candidate
receive_external.run_release_once(config_file=path)
deploy.assert_called_once_with(
candidate, repository=receive_external.DEFAULT_REPOSITORY,
ssh="ssh", ssh_alias="akyldash_release")
prepared = {"version": 1, "action": "release", "run_id": "302",
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
query.return_value = prepared
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
deploy.assert_not_called()
self.assertEqual(receive_external.read_prepared_request(Path(directory) / "prepared.json"), prepared)
with patch.object(receive_external.sys.stdin, "isatty", return_value=False):
with self.assertRaisesRegex(ValueError, "interactive owner confirmation"):
receive_external.apply_prepared_release(
config_file=path, prepared_file=Path(directory) / "prepared.json",
)
with patch.object(receive_external.sys.stdin, "isatty", return_value=True), \
patch.object(receive_external, "run_release") as apply:
receive_external.apply_prepared_release(
config_file=path, prepared_file=Path(directory) / "prepared.json",
confirm=lambda prompt: f"APPLY {'a' * 40}",
)
apply.assert_called_once_with(
prepared, repository=receive_external.DEFAULT_REPOSITORY,
ssh="ssh", ssh_alias="akyldash_release")
def test_auto_apply_requires_a_prior_matching_prepared_poll_and_separate_gate(self):
previous = {"version": 1, "action": "release", "run_id": "301",
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
latest = {"version": 1, "action": "release", "run_id": "302",
"commit": "c" * 40, "descriptor_sha256": "d" * 64}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
prepared_file = root / "prepared.json"
trigger_file = root / "trigger.json"
auto_file = root / "auto.json"
with patch.object(receive_external, "check_private_acl"):
receive_external.write_prepared_request(previous, prepared_file)
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True,
"repository": str(receive_external.DEFAULT_REPOSITORY), "ssh_alias": "akyldash_release"}), \
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": True}), \
patch.object(receive_external, "query_release_status", return_value=latest) as query, \
patch.object(receive_external, "run_release") as deploy:
receive_external.run_auto_apply_once(
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
)
query.assert_called_once()
deploy.assert_not_called()
self.assertEqual(receive_external.read_prepared_request(prepared_file), latest)
query.reset_mock()
query.return_value = latest
receive_external.run_auto_apply_once(
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
)
deploy.assert_called_once_with(
latest, repository=receive_external.DEFAULT_REPOSITORY,
ssh="ssh", ssh_alias="akyldash_release",
)
def test_auto_apply_disabled_does_not_open_ssh(self):
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True}), \
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": False}), \
patch.object(receive_external, "query_release_status") as query, \
patch.object(receive_external, "run_release") as deploy:
receive_external.run_auto_apply_once()
query.assert_not_called()
deploy.assert_not_called()
def test_windows_trigger_templates_are_fixed_and_disabled(self):
root = Path(__file__).resolve().parents[1] / "deploy"
@@ -306,7 +371,16 @@ class ExternalReceiverTests(unittest.TestCase):
self.assertEqual(task.findtext("t:Principals/t:Principal/t:RunLevel", namespaces=ns), "LeastPrivilege")
self.assertEqual(
task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
'"C:\\Program Files\\Akyldash\\receive_external.py" --once',
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --once',
)
auto_config = json.loads((root / "windows-release-auto-apply.example.json").read_text(encoding="utf-8"))
self.assertEqual(auto_config, {"schema_version": 1, "enabled": False})
auto_task = ET.parse(root / "windows-release-auto-apply.example.xml").getroot()
self.assertEqual(auto_task.findtext("t:Settings/t:Enabled", namespaces=ns), "false")
self.assertEqual(auto_task.findtext("t:Triggers/t:CalendarTrigger/t:Enabled", namespaces=ns), "false")
self.assertEqual(
auto_task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --auto-apply-prepared',
)
+12 -5
View File
@@ -146,15 +146,21 @@ def deploy_external(
if not re.fullmatch(r"[0-9a-f]{64}", descriptor_sha256):
raise ValueError("release descriptor digest is invalid")
from release_handoff import latest_main_sha
if latest_main_sha(ROOT) != manifest["commit"]:
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
from release_source import require_runtime_compatibility
def verify_release_still_current() -> None:
if latest_main_sha(ROOT) != manifest["commit"]:
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
require_runtime_compatibility(
manifest["commit"], checkout=ROOT,
policy_file=Path("/etc/akyldash/release-policy.json"),
)
verify_release_still_current()
if not isinstance(current_before_stop, dict) or image_ids(allow_stopped=True) != current_before_stop:
raise ValueError("stopped services do not match the pre-backup image snapshot")
if app_images(backup_value, "external backup images") != current_before_stop:
raise ValueError("external backup image identities do not match the current deployment")
repository_revision = run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture=True)
if repository_revision != manifest["commit"]:
raise ValueError("host checkout does not match the verified release commit")
current_files = {
name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest()
for name in ("deploy/production/compose.yaml", "deploy/production/Caddyfile")
@@ -195,6 +201,7 @@ def deploy_external(
}
journal.update({"status": "external-backup-verified", "target": target})
write_json_atomic(JOURNAL_FILE, journal)
verify_release_still_current()
replace_images(ENV_FILE, old_env, new_env)
journal["status"] = "references-switched"
write_json_atomic(JOURNAL_FILE, journal)
+9 -3
View File
@@ -12,7 +12,7 @@ from archive import compose_sources
from deployment_lock import deployment_lock
from deploy_release import JOURNAL_FILE, ROOT, SERVICES, compose, deploy_external, image_ids
from external_session import read_request, send_backup_session, validate_request, write_result
from release_source import fetch_release
from release_source import fetch_release, require_runtime_compatibility
from release_state import read_json, validate_state, write_json_atomic
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
@@ -54,15 +54,21 @@ def execute(request: dict, *, reader, writer, policy: dict | None = None, api=No
request, policy=policy, current_main=current_main, checkout=ROOT,
**({"api": api} if api is not None else {}),
)
require_runtime_compatibility(
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
**({"api": api} if api is not None else {}),
)
with deployment_lock():
require_runtime_compatibility(
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
**({"api": api} if api is not None else {}),
)
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release")
current = image_ids()
current_record = {service: current[service] for service in SERVICES}
revision = deploy_release_revision()
if revision != manifest["commit"]:
raise ValueError("installed host checkout does not match the verified release commit")
previous_state = None
if STATE_FILE.exists() or STATE_FILE.is_symlink():
previous_state = validate_state(read_json(STATE_FILE))
+9 -22
View File
@@ -1,50 +1,37 @@
#!/usr/bin/env python3
"""Read-only selector for one fully verified current-main release candidate."""
"""Read-only preparation endpoint for one fully verified current-main release."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from deploy_release import JOURNAL_FILE, ROOT, STATE_FILE
from release_handoff import latest_main_sha
from release_source import fetch_release, latest_release_request
from release_source import fetch_release, latest_release_request, runtime_compatibility_reason
from release_state import read_json, validate_state
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
STATUS_MAX_BYTES = 1024
SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
def checkout_sha(checkout: Path) -> str:
result = subprocess.run(
["git", "-C", str(checkout), "rev-parse", "HEAD"],
check=True, text=True, capture_output=True, timeout=10,
)
value = result.stdout.strip()
if not SHA_RE.fullmatch(value):
raise ValueError("production checkout revision is invalid")
return value
def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_FILE,
policy_file: Path = POLICY_FILE, state_file: Path = STATE_FILE,
main_lookup=latest_main_sha,
checkout_lookup=checkout_sha) -> dict:
main_lookup=latest_main_sha) -> dict:
if os.geteuid() != 0:
raise ValueError("release status command must run as root")
if journal.exists() or journal.is_symlink():
return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None}
current_main = main_lookup(checkout)
if checkout_lookup(checkout) != current_main:
return {"schema_version": 1, "state": "not_ready", "reason": "host_checkout_stale", "release": None}
api_args = {"api": api} if api is not None else {}
reason = runtime_compatibility_reason(
current_main, checkout=checkout, policy_file=policy_file, **api_args,
)
if reason is not None:
return {"schema_version": 1, "state": "not_ready", "reason": reason, "release": None}
request = latest_release_request(current_main=current_main, checkout=checkout, **api_args)
if request is None:
return {"schema_version": 1, "state": "not_ready", "reason": "no_current_main_release", "release": None}
@@ -70,7 +57,7 @@ def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_
return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None}
return {
"schema_version": 1,
"state": "candidate",
"state": "prepared",
"reason": None,
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
}
@@ -0,0 +1,385 @@
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9a-f]{40}$')]
[string]$ExpectedCommit,
[Parameter(Mandatory = $true)]
[string]$BundlePath,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9a-f]{64}$')]
[string]$ExpectedBundleSha256
)
$ErrorActionPreference = 'Stop'
$python = 'C:\Python313\python.exe'
$git = 'C:\Program Files\Git\cmd\git.exe'
$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'
$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
$appRoot = 'C:\Program Files\Akyldash'
$app = Join-Path $appRoot "client-$ExpectedCommit"
$programData = 'C:\ProgramData\Akyldash'
$trigger = Join-Path $programData 'release-trigger.json'
$autoApply = Join-Path $programData 'release-auto-apply.json'
$mutationRights = [System.Security.AccessControl.FileSystemRights](
[int][System.Security.AccessControl.FileSystemRights]::WriteData -bor
[int][System.Security.AccessControl.FileSystemRights]::AppendData -bor
[int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
[int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
)
$pathReplacementRights = [System.Security.AccessControl.FileSystemRights](
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
)
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this reviewed bootstrap from an elevated administrator session.'
}
$signature = Get-AuthenticodeSignature -LiteralPath $python
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Python Software Foundation') {
throw 'The pinned Python interpreter does not have the verified Python Software Foundation signature.'
}
function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) {
$item = Get-Item -LiteralPath $Path -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse path: $Path" }
$acl = Get-Acl -LiteralPath $Path
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
if ($AllowOwnerWrite) {
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
} else {
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
}
if ($ownerSid -notin $approvedOwners) { throw "Protected path has an unapproved owner: $Path" }
if ($AllowOwnerWrite) {
$allowed = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
foreach ($rule in $rules) {
$sid = $rule.IdentityReference.Value
if ($rule.AccessControlType -ne 'Allow' -or $sid -notin $allowed -or
($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) {
throw "Private state path ACL must grant only full control to owner/SYSTEM/Administrators: $Path"
}
}
$ownerHasFullControl = @($rules | Where-Object {
$_.AccessControlType -eq 'Allow' -and
$_.IdentityReference.Value -eq $identity.User.Value -and
($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl
}).Count -gt 0
if (-not $ownerHasFullControl) { throw "Task owner needs full control on protected state path: $Path" }
} else {
$writableUntrusted = @($rules | Where-Object {
$_.AccessControlType -eq 'Allow' -and
(([int]$_.FileSystemRights -band [int]$mutationRights) -ne 0) -and
$_.IdentityReference.Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
})
if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" }
}
}
function Set-TrustedCodeOwner([string]$Path) {
$acl = Get-Acl -LiteralPath $Path
$administratorSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$acl.SetOwner($administratorSid)
Set-Acl -LiteralPath $Path -AclObject $acl
}
function Assert-TrustedPath([string]$Path, [bool]$ParentsOnly = $false) {
$item = Get-Item -LiteralPath $Path -Force
if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Refusing missing, non-filesystem, or reparse protected path: $Path"
}
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
$current = if ($ParentsOnly) { $item.Parent } else { $item }
$isLeaf = -not $ParentsOnly
while ($null -ne $current) {
if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Protected path contains a reparse point: $($current.FullName)"
}
$acl = Get-Acl -LiteralPath $current.FullName
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
if ($ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
throw "Protected path has an untrusted owner: $($current.FullName)"
}
$checkedRights = if ($isLeaf) { $mutationRights } else { $pathReplacementRights }
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
foreach ($rule in $rules) {
$sid = $rule.IdentityReference.Value
if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }
if ($rule.AccessControlType -eq 'Allow' -and
(([int]$rule.FileSystemRights -band [int]$checkedRights) -ne 0) -and
$sid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
throw "Protected path grants replacement/write access outside trusted system owners: $($current.FullName)"
}
}
$current = $current.Parent
$isLeaf = $false
}
}
foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }
Assert-ProtectedPath (Split-Path -Parent $python)
Assert-ProtectedPath $python
if (-not (Test-Path -LiteralPath $appRoot -PathType Container)) { throw 'Protected receiver directory is missing; provision it through the reviewed owner procedure first.' }
Assert-TrustedPath $appRoot
Assert-ProtectedPath $appRoot
if (Test-Path -LiteralPath $app) { throw 'Versioned receiver destination already exists; inspect it manually instead of overwriting.' }
if (-not (Test-Path -LiteralPath $programData -PathType Container)) { throw 'Protected ProgramData directory is missing; provision it through the reviewed owner procedure first.' }
Assert-ProtectedPath $programData -AllowOwnerWrite $true
Assert-TrustedPath $programData -ParentsOnly $true
if (Test-Path -LiteralPath $trigger) { Assert-ProtectedPath $trigger -AllowOwnerWrite $true }
if (Test-Path -LiteralPath $trigger) {
$triggerItem = Get-Item -LiteralPath $trigger
if ($triggerItem.Length -gt 4096) { throw 'Existing trigger configuration exceeds its size limit.' }
$triggerConfig = Get-Content -LiteralPath $trigger -Raw | ConvertFrom-Json
$triggerFields = @($triggerConfig.PSObject.Properties.Name | Sort-Object) -join ','
if ($triggerConfig.schema_version -ne 1 -or $triggerConfig.enabled -ne $false -or
$triggerConfig.repository -ne 'D:\Dev\akyldash' -or $triggerConfig.ssh_alias -ne 'akyldash_release' -or
$triggerFields -ne 'enabled,repository,schema_version,ssh_alias') {
throw 'Existing trigger configuration is not the fixed disabled configuration.'
}
}
if (Test-Path -LiteralPath $autoApply) {
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
$autoItem = Get-Item -LiteralPath $autoApply
if ($autoItem.Length -gt 1024) { throw 'Existing auto-apply configuration exceeds its size limit.' }
$autoConfig = Get-Content -LiteralPath $autoApply -Raw | ConvertFrom-Json
$autoFields = @($autoConfig.PSObject.Properties.Name | Sort-Object) -join ','
if ($autoConfig.schema_version -ne 1 -or $autoConfig.enabled -ne $false -or
$autoFields -ne 'enabled,schema_version') {
throw 'Existing auto-apply configuration must be disabled and match its fixed schema.'
}
}
function Read-BoundedZipEntry($Entry, [long]$Maximum) {
if ($Entry.Length -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" }
$inputStream = $Entry.Open()
$buffer = [IO.MemoryStream]::new()
try {
$chunk = New-Object byte[] 8192
while (($count = $inputStream.Read($chunk, 0, $chunk.Length)) -gt 0) {
if ($buffer.Length + $count -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" }
$buffer.Write($chunk, 0, $count)
}
if ($buffer.Length -ne $Entry.Length) { throw "Receiver bundle entry length is invalid: $($Entry.FullName)" }
return ,$buffer.ToArray()
} finally {
$inputStream.Dispose()
$buffer.Dispose()
}
}
function Assert-ReceiverBundleEntryMode($Entry) {
$entryMode = (([long]$Entry.ExternalAttributes -shr 16) -band 0xFFFF)
if ($entryMode -ne 0x8180) { throw "Receiver bundle entry has an unsafe file mode: $($Entry.FullName)" }
}
function Get-LowerHex([byte[]]$Bytes, [bool]$UseSha1 = $false) {
$algorithm = if ($UseSha1) { [Security.Cryptography.SHA1]::Create() } else { [Security.Cryptography.SHA256]::Create() }
try { return [BitConverter]::ToString($algorithm.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
finally { $algorithm.Dispose() }
}
function Get-GitObjectHex([string]$Kind, [byte[]]$Bytes) {
$header = [Text.Encoding]::ASCII.GetBytes("$Kind $($Bytes.Length)`0")
$payload = New-Object byte[] ($header.Length + $Bytes.Length)
[Array]::Copy($header, 0, $payload, 0, $header.Length)
[Array]::Copy($Bytes, 0, $payload, $header.Length, $Bytes.Length)
return Get-LowerHex -Bytes $payload -UseSha1 $true
}
function Get-GitTreeEntries([byte[]]$Bytes) {
$entries = @{}
$offset = 0
$utf8Strict = [Text.UTF8Encoding]::new($false, $true)
while ($offset -lt $Bytes.Length) {
$space = [Array]::IndexOf($Bytes, [byte]32, $offset)
if ($space -lt $offset) { throw 'Git tree object has a malformed mode field.' }
$nul = [Array]::IndexOf($Bytes, [byte]0, $space + 1)
if ($nul -lt $space -or $nul + 21 -gt $Bytes.Length) { throw 'Git tree object has a malformed entry.' }
$mode = [Text.Encoding]::ASCII.GetString($Bytes, $offset, $space - $offset)
$name = $utf8Strict.GetString($Bytes, $space + 1, $nul - $space - 1)
if (-not $name -or $name -in @('.', '..') -or $entries.ContainsKey($name)) { throw 'Git tree object has an invalid or duplicate name.' }
$objectBytes = New-Object byte[] 20
[Array]::Copy($Bytes, $nul + 1, $objectBytes, 0, 20)
$entries[$name] = [PSCustomObject]@{
mode = $mode
sha1 = [BitConverter]::ToString($objectBytes).Replace('-', '').ToLowerInvariant()
}
$offset = $nul + 21
}
return ,$entries
}
# The package is opened once with write/delete sharing denied. Its bytes are
# hashed and held in memory before any installation write; no checkout or Git
# command participates in this elevated stage.
$bundleItem = Get-Item -LiteralPath $BundlePath -Force
if ($bundleItem -isnot [IO.FileInfo] -or ($bundleItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Receiver bundle must be a regular non-reparse file.'
}
$bundleStream = [IO.File]::Open([IO.Path]::GetFullPath($BundlePath), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
$zip = $null
$verifiedFiles = @{}
try {
if ($bundleStream.Length -le 0 -or $bundleStream.Length -gt 524288) { throw 'Receiver bundle has an invalid size.' }
$bundleHasher = [Security.Cryptography.SHA256]::Create()
try { $bundleHash = [BitConverter]::ToString($bundleHasher.ComputeHash($bundleStream)).Replace('-', '').ToLowerInvariant() }
finally { $bundleHasher.Dispose() }
if ($bundleHash -ne $ExpectedBundleSha256) { throw 'Receiver bundle SHA-256 does not match ExpectedBundleSha256.' }
$bundleStream.Position = 0
Add-Type -AssemblyName System.IO.Compression
$zip = [System.IO.Compression.ZipArchive]::new($bundleStream, [IO.Compression.ZipArchiveMode]::Read, $true)
$expectedEntries = @('manifest.json', 'objects/commit.bin', 'objects/root-tree.bin',
'objects/infra-tree.bin', 'objects/backup-tree.bin', 'objects/deploy-tree.bin',
'files/archive.py', 'files/external_session.py',
'files/receive_external.py', 'files/windows-release-trigger.example.xml',
'files/windows_release_launcher.py')
$entryNames = @($zip.Entries | ForEach-Object { $_.FullName })
if ($entryNames.Count -ne $expectedEntries.Count -or
(@($entryNames | Select-Object -Unique).Count -ne $expectedEntries.Count)) {
throw 'Receiver bundle contains duplicate or unexpected entries.'
}
foreach ($expectedEntry in $expectedEntries) {
if ($entryNames -cnotcontains $expectedEntry) { throw "Receiver bundle is missing an exact required entry: $expectedEntry" }
}
foreach ($zipEntry in $zip.Entries) {
Assert-ReceiverBundleEntryMode -Entry $zipEntry
}
$manifestBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('manifest.json') -Maximum 16384
$utf8Strict = [Text.UTF8Encoding]::new($false, $true)
$manifest = $utf8Strict.GetString($manifestBytes) | ConvertFrom-Json
$manifestFields = @($manifest.PSObject.Properties.Name | Sort-Object) -join ','
if ($manifestFields -ne 'commit,files,schema_version,trees' -or
($manifest.schema_version -isnot [int] -and $manifest.schema_version -isnot [long]) -or
$manifest.schema_version -ne 1 -or $manifest.commit -isnot [string] -or
$manifest.commit -cne $ExpectedCommit -or $manifest.files -isnot [Array] -or
@($manifest.files).Count -ne 5 -or $manifest.trees -isnot [Array] -or @($manifest.trees).Count -ne 4) {
throw 'Receiver bundle manifest does not match the expected commit and schema.'
}
$commitBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('objects/commit.bin') -Maximum 16384
if ((Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes) -cne $ExpectedCommit) {
throw 'Receiver bundle Git commit object does not match ExpectedCommit.'
}
$commitText = [Text.Encoding]::ASCII.GetString($commitBytes)
$headerEnd = $commitText.IndexOf("`n`n", [StringComparison]::Ordinal)
if ($headerEnd -lt 1) { throw 'Receiver bundle commit header is malformed.' }
$treeHeaders = [regex]::Matches($commitText.Substring(0, $headerEnd), '(?m)^tree ([0-9a-f]{40})$')
if ($treeHeaders.Count -ne 1) { throw 'Receiver bundle commit has no unique root tree reference.' }
$treeEntries = @{}
$treeObjectNames = @{
'' = 'objects/root-tree.bin'
infra = 'objects/infra-tree.bin'
'infra/backup' = 'objects/backup-tree.bin'
'infra/deploy' = 'objects/deploy-tree.bin'
}
$seenTrees = @{}
foreach ($tree in $manifest.trees) {
$treeFields = @($tree.PSObject.Properties.Name | Sort-Object) -join ','
if ($treeFields -ne 'path,sha1' -or $tree.path -isnot [string] -or
$tree.path -cnotin @('', 'infra', 'infra/backup', 'infra/deploy') -or
$seenTrees.ContainsKey($tree.path) -or $tree.sha1 -cnotmatch '^[0-9a-f]{40}$') {
throw 'Receiver bundle tree manifest is invalid.'
}
$treeData = Read-BoundedZipEntry -Entry $zip.GetEntry($treeObjectNames[$tree.path]) -Maximum 262144
if ((Get-GitObjectHex -Kind 'tree' -Bytes $treeData) -cne $tree.sha1) {
throw "Receiver bundle tree object identity does not match its manifest: $($tree.path)"
}
$treeEntries[$tree.path] = Get-GitTreeEntries -Bytes $treeData
$seenTrees[$tree.path] = $tree.sha1
}
if ($seenTrees.Count -ne 4 -or $treeHeaders[0].Groups[1].Value -cne $seenTrees['']) {
throw 'Receiver bundle commit does not bind the declared root tree.'
}
$rootInfra = $treeEntries['']['infra']
$infraBackup = $treeEntries['infra']['backup']
$infraDeploy = $treeEntries['infra']['deploy']
if ($rootInfra.mode -notin @('40000', '040000') -or $rootInfra.sha1 -cne $seenTrees['infra'] -or
$infraBackup.mode -notin @('40000', '040000') -or $infraBackup.sha1 -cne $seenTrees['infra/backup'] -or
$infraDeploy.mode -notin @('40000', '040000') -or $infraDeploy.sha1 -cne $seenTrees['infra/deploy']) {
throw 'Receiver bundle directory tree chain does not match the commit.'
}
$allowedFiles = @('archive.py', 'external_session.py', 'receive_external.py',
'windows-release-trigger.example.xml', 'windows_release_launcher.py')
$seenFiles = @{}
foreach ($file in $manifest.files) {
$fileFields = @($file.PSObject.Properties.Name | Sort-Object) -join ','
if ($fileFields -ne 'git_blob_sha1,path,sha256,size' -or $file.path -isnot [string] -or
$file.path -cnotin $allowedFiles -or $seenFiles.ContainsKey($file.path) -or
$file.git_blob_sha1 -isnot [string] -or $file.git_blob_sha1 -cnotmatch '^[0-9a-f]{40}$' -or
$file.sha256 -isnot [string] -or $file.sha256 -cnotmatch '^[0-9a-f]{64}$' -or
($file.size -isnot [int] -and $file.size -isnot [long]) -or $file.size -lt 0 -or $file.size -gt 262144) {
throw 'Receiver bundle manifest contains an invalid file record.'
}
$entry = $zip.GetEntry("files/$($file.path)")
$data = Read-BoundedZipEntry -Entry $entry -Maximum 262144
if ($data.Length -ne [long]$file.size -or
(Get-LowerHex -Bytes $data) -cne $file.sha256) {
throw "Receiver bundle file digest or size does not match its manifest: $($file.path)"
}
if ((Get-GitObjectHex -Kind 'blob' -Bytes $data) -cne $file.git_blob_sha1) {
throw "Receiver bundle Git blob identity does not match its manifest: $($file.path)"
}
$treePath = if ($file.path -in @('archive.py', 'external_session.py', 'receive_external.py')) { 'infra/backup' } else { 'infra/deploy' }
$treeFile = $treeEntries[$treePath][$file.path]
if ($null -eq $treeFile -or $treeFile.mode -ne '100644' -or $treeFile.sha1 -cne $file.git_blob_sha1) {
throw "Receiver bundle commit tree does not contain the exact source file: $($file.path)"
}
$seenFiles[$file.path] = $true
$verifiedFiles[$file.path] = $data
}
if ($seenFiles.Count -ne $allowedFiles.Count) { throw 'Receiver bundle manifest omitted a required file.' }
} finally {
if ($null -ne $zip) { $zip.Dispose() }
$bundleStream.Dispose()
}
$appSecurity = Get-Acl -LiteralPath $appRoot
$appSecurity.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))
$null = [IO.Directory]::CreateDirectory($app, $appSecurity)
Set-TrustedCodeOwner $app
Assert-ProtectedPath $app
foreach ($name in @('receive_external.py', 'archive.py', 'external_session.py', 'windows_release_launcher.py')) {
$destination = Join-Path $app $name
[IO.File]::WriteAllBytes($destination, $verifiedFiles[$name])
Set-TrustedCodeOwner $destination
Assert-ProtectedPath $destination
}
if (-not (Test-Path -LiteralPath $trigger)) {
$config = '{"schema_version":1,"enabled":false,"repository":"D:\\Dev\\akyldash","ssh_alias":"akyldash_release"}'
Set-Content -LiteralPath $trigger -Value $config -Encoding Ascii -NoNewline
}
if (-not (Test-Path -LiteralPath $autoApply)) {
Set-Content -LiteralPath $autoApply -Value '{"schema_version":1,"enabled":false}' -Encoding Ascii -NoNewline
}
Assert-ProtectedPath $trigger -AllowOwnerWrite $true
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
$template = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml'])
$ns = [System.Xml.XmlNamespaceManager]::new($template.NameTable)
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
$sid = $identity.User.Value
$template.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $ns).InnerText = $sid
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $ns).InnerText = $python
$launcher = Join-Path $app 'windows_release_launcher.py'
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $ns).InnerText = "-B -E -s -S `"$launcher`" --once"
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $ns).InnerText = $app
$template.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $ns).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
$templatePath = Join-Path $programData 'Akyldash-ReleaseTrigger-disabled.xml'
$template.Save($templatePath)
$applyTemplate = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml'])
$applyNs = [System.Xml.XmlNamespaceManager]::new($applyTemplate.NameTable)
$applyNs.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
$applyTemplate.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI', $applyNs).InnerText = '\Akyldash\ReleaseAutoApply'
$applyTemplate.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $applyNs).InnerText = $sid
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $applyNs).InnerText = $python
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $applyNs).InnerText = "-B -E -s -S `"$launcher`" --auto-apply-prepared"
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $applyNs).InnerText = $app
$applyTemplate.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $applyNs).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
$applyTemplate.Save((Join-Path $programData 'Akyldash-ReleaseAutoApply-disabled.xml'))
Write-Output "Prepared disabled receiver files from verified source commit $ExpectedCommit."
Write-Output 'No task was registered or enabled; both configuration gates remain disabled.'
@@ -0,0 +1,309 @@
"""Build a small immutable Windows receiver bundle from one Git commit."""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import re
import subprocess
import zipfile
from pathlib import Path
FILES = {
"archive.py": "infra/backup/archive.py",
"external_session.py": "infra/backup/external_session.py",
"receive_external.py": "infra/backup/receive_external.py",
"windows-release-trigger.example.xml": "infra/deploy/windows-release-trigger.example.xml",
"windows_release_launcher.py": "infra/deploy/windows_release_launcher.py",
}
MAX_BUNDLE_BYTES = 512 * 1024
MAX_FILE_BYTES = 262144
TREE_OBJECTS = {
"": "objects/root-tree.bin",
"infra": "objects/infra-tree.bin",
"infra/backup": "objects/backup-tree.bin",
"infra/deploy": "objects/deploy-tree.bin",
}
def _git(repository: Path, *arguments: str) -> bytes:
result = subprocess.run(
["git", "-C", str(repository), *arguments],
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
)
return result.stdout
def _blob_sha(data: bytes) -> str:
return _git_object_sha("blob", data)
def _git_object_sha(kind: str, data: bytes) -> str:
return hashlib.sha1(kind.encode("ascii") + b" " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest()
def _tree_entry(repository: Path, tree: str, name: str) -> tuple[str, str, str]:
records = _git(repository, "ls-tree", "-z", tree, "--", name).split(b"\0")
matches = []
for record in records:
if not record:
continue
header, raw_path = record.split(b"\t", 1)
mode, kind, object_id = header.decode("ascii").split(" ")
if raw_path.decode("utf-8") == name:
matches.append((mode, kind, object_id))
if len(matches) != 1:
raise ValueError(f"Git tree is missing or duplicates required path: {name}")
return matches[0]
def _parse_git_tree(data: bytes) -> dict[str, tuple[str, str]]:
entries: dict[str, tuple[str, str]] = {}
offset = 0
while offset < len(data):
space = data.find(b" ", offset)
nul = data.find(b"\0", space + 1) if space >= 0 else -1
if space < offset or nul < 0 or nul + 21 > len(data):
raise ValueError("Git tree object is malformed")
mode = data[offset:space].decode("ascii")
name = data[space + 1:nul].decode("utf-8")
object_id = data[nul + 1:nul + 21].hex()
if not name or name in {".", ".."} or name in entries:
raise ValueError("Git tree object has an invalid or duplicate name")
entries[name] = (mode, object_id)
offset = nul + 21
return entries
def _strict_json_object(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError("receiver bundle manifest has duplicate keys")
value[key] = item
return value
def verify_bundle(payload: bytes, expected_commit: str, expected_sha256: str) -> dict[str, bytes]:
"""Verify a generated or transferred bundle before an installer consumes it."""
if len(payload) <= 0 or len(payload) > MAX_BUNDLE_BYTES:
raise ValueError("receiver bundle has an invalid size")
if hashlib.sha256(payload).hexdigest() != expected_sha256:
raise ValueError("receiver bundle SHA-256 does not match")
if not re.fullmatch(r"[0-9a-f]{40}", expected_commit):
raise ValueError("expected commit must be a full lowercase Git SHA-1")
expected_entries = {
"manifest.json", "objects/commit.bin", *TREE_OBJECTS.values(),
*(f"files/{name}" for name in FILES),
}
try:
archive = zipfile.ZipFile(io.BytesIO(payload))
except (zipfile.BadZipFile, OSError) as error:
raise ValueError("receiver bundle is not a valid ZIP") from error
with archive:
infos = archive.infolist()
names = [item.filename for item in infos]
if len(names) != len(expected_entries) or len(set(names)) != len(expected_entries) or set(names) != expected_entries:
raise ValueError("receiver bundle has duplicate, missing, or unexpected entries")
for item in infos:
mode = (item.external_attr >> 16) & 0xFFFF
if mode != 0o100600 or item.compress_type != zipfile.ZIP_STORED:
raise ValueError("receiver bundle entry has an unsafe mode or compression method")
maximum = 16384 if item.filename == "manifest.json" else MAX_FILE_BYTES
if item.file_size > maximum:
raise ValueError("receiver bundle entry exceeds its size limit")
contents = {item.filename: archive.read(item) for item in infos}
try:
manifest = json.loads(contents["manifest.json"].decode("ascii"), object_pairs_hook=_strict_json_object)
except (UnicodeError, json.JSONDecodeError) as error:
raise ValueError("receiver bundle manifest is invalid") from error
if (not isinstance(manifest, dict) or set(manifest) != {"schema_version", "commit", "files", "trees"}
or type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1
or manifest["commit"] != expected_commit):
raise ValueError("receiver bundle manifest does not match the expected commit and schema")
commit_object = contents["objects/commit.bin"]
if _git_object_sha("commit", commit_object) != expected_commit:
raise ValueError("receiver bundle commit object does not match expected SHA")
commit_header = commit_object.split(b"\n\n", 1)[0]
commit_trees = re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header)
if len(commit_trees) != 1:
raise ValueError("receiver bundle commit has no unique root tree")
if not isinstance(manifest["trees"], list) or len(manifest["trees"]) != len(TREE_OBJECTS):
raise ValueError("receiver bundle tree manifest is invalid")
tree_hashes: dict[str, str] = {}
tree_entries: dict[str, dict[str, tuple[str, str]]] = {}
for record in manifest["trees"]:
if (not isinstance(record, dict) or set(record) != {"path", "sha1"}
or not isinstance(record["path"], str) or record["path"] not in TREE_OBJECTS
or record["path"] in tree_hashes
or not isinstance(record["sha1"], str) or not re.fullmatch(r"[0-9a-f]{40}", record["sha1"])):
raise ValueError("receiver bundle tree record is invalid")
path = record["path"]
data = contents[TREE_OBJECTS[path]]
if _git_object_sha("tree", data) != record["sha1"]:
raise ValueError(f"receiver bundle tree hash does not match: {path}")
tree_hashes[path] = record["sha1"]
tree_entries[path] = _parse_git_tree(data)
if set(tree_hashes) != set(TREE_OBJECTS) or tree_hashes[""].encode("ascii") != commit_trees[0]:
raise ValueError("receiver bundle commit does not bind the declared root tree")
for parent, name, child in (("", "infra", "infra"),
("infra", "backup", "infra/backup"),
("infra", "deploy", "infra/deploy")):
mode, child_sha = tree_entries[parent].get(name, ("", ""))
if mode not in {"40000", "040000"} or child_sha != tree_hashes[child]:
raise ValueError(f"receiver bundle directory tree chain is invalid at {parent or '/'}/{name}")
if not isinstance(manifest["files"], list) or len(manifest["files"]) != len(FILES):
raise ValueError("receiver bundle file manifest is invalid")
verified: dict[str, bytes] = {}
for record in manifest["files"]:
if (not isinstance(record, dict) or set(record) != {"path", "git_blob_sha1", "sha256", "size"}
or not isinstance(record["path"], str) or record["path"] not in FILES or record["path"] in verified
or type(record["size"]) is not int or record["size"] < 0 or record["size"] > MAX_FILE_BYTES
or not isinstance(record["git_blob_sha1"], str)
or not re.fullmatch(r"[0-9a-f]{40}", record["git_blob_sha1"])
or not isinstance(record["sha256"], str)
or not re.fullmatch(r"[0-9a-f]{64}", record["sha256"])):
raise ValueError("receiver bundle file record is invalid")
name = record["path"]
data = contents[f"files/{name}"]
tree_path = "infra/backup" if FILES[name].startswith("infra/backup/") else "infra/deploy"
mode, blob_sha = tree_entries[tree_path].get(name, ("", ""))
if (mode != "100644" or blob_sha != record["git_blob_sha1"]
or _git_object_sha("blob", data) != blob_sha
or hashlib.sha256(data).hexdigest() != record["sha256"]
or len(data) != record["size"]):
raise ValueError(f"receiver bundle file does not match its commit tree: {name}")
verified[name] = data
if set(verified) != set(FILES):
raise ValueError("receiver bundle omits a required source file")
return verified
def read_commit_files(repository: Path, commit: str) -> tuple[
dict[str, bytes], list[dict[str, object]], dict[str, bytes], list[dict[str, str]]]:
if not re.fullmatch(r"[0-9a-f]{40}", commit):
raise ValueError("expected commit must be a full lowercase Git SHA-1")
if repository.is_symlink():
raise ValueError("repository must be a real directory")
repository = repository.resolve(strict=True)
if not repository.is_dir():
raise ValueError("repository must be a real directory")
if _git(repository, "cat-file", "-t", commit).strip() != b"commit":
raise ValueError("expected object is not a commit")
main_commit = _git(repository, "rev-parse", "refs/heads/main").strip().decode("ascii")
if main_commit != commit:
raise ValueError("expected commit must equal the local main branch tip")
commit_object = _git(repository, "cat-file", "commit", commit)
if _git_object_sha("commit", commit_object) != commit:
raise ValueError("commit object bytes do not match the expected SHA")
commit_header = commit_object.split(b"\n\n", 1)[0]
commit_trees = [value.decode("ascii") for value in
re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header)]
if len(commit_trees) != 1:
raise ValueError("commit object has no unique root tree")
tree_ids = {"": commit_trees[0]}
tree_ids["infra"] = _tree_entry(repository, tree_ids[""], "infra")[2]
tree_ids["infra/backup"] = _tree_entry(repository, tree_ids["infra"], "backup")[2]
tree_ids["infra/deploy"] = _tree_entry(repository, tree_ids["infra"], "deploy")[2]
tree_objects: dict[str, bytes] = {"objects/commit.bin": commit_object}
manifest_trees: list[dict[str, str]] = []
for tree_path, tree_sha in sorted(tree_ids.items()):
data = _git(repository, "cat-file", "tree", tree_sha)
if _git_object_sha("tree", data) != tree_sha:
raise ValueError(f"Git tree bytes do not match their object ID: {tree_path or '/'}")
tree_objects[TREE_OBJECTS[tree_path]] = data
manifest_trees.append({"path": tree_path, "sha1": tree_sha})
records = _git(repository, "ls-tree", "-z", commit, "--", *FILES.values()).split(b"\0")
tree_entries: dict[str, tuple[str, str, str]] = {}
for record in records:
if not record:
continue
header, raw_path = record.split(b"\t", 1)
mode, kind, object_id = header.decode("ascii").split(" ")
tree_entries[raw_path.decode("utf-8")] = (mode, kind, object_id)
if set(tree_entries) != set(FILES.values()):
raise ValueError("commit does not contain the exact receiver allowlist")
contents: dict[str, bytes] = {}
manifest_files: list[dict[str, object]] = []
for name, source_path in sorted(FILES.items()):
mode, kind, object_id = tree_entries[source_path]
if mode != "100644" or kind != "blob":
raise ValueError(f"receiver source is not a regular Git blob: {source_path}")
data = _git(repository, "cat-file", "blob", object_id)
if _blob_sha(data) != object_id:
raise ValueError(f"Git blob bytes do not match the commit object: {source_path}")
contents[name] = data
manifest_files.append({
"path": name,
"git_blob_sha1": object_id,
"sha256": hashlib.sha256(data).hexdigest(),
"size": len(data),
})
for name, source_path in FILES.items():
parent_path = "infra/backup" if source_path.startswith("infra/backup/") else "infra/deploy"
_mode, _kind, expected_blob = tree_entries[source_path]
mode, kind, tree_blob = _tree_entry(repository, tree_ids[parent_path], name)
if mode != "100644" or kind != "blob" or tree_blob != expected_blob:
raise ValueError(f"Git tree path does not bind the expected regular source blob: {source_path}")
return contents, manifest_files, tree_objects, manifest_trees
def create_bundle(repository: Path, commit: str) -> bytes:
contents, manifest_files, tree_objects, manifest_trees = read_commit_files(repository, commit)
manifest = json.dumps({
"schema_version": 1,
"commit": commit,
"files": manifest_files,
"trees": manifest_trees,
}, sort_keys=True, separators=(",", ":")).encode("ascii") + b"\n"
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as bundle:
members = [("manifest.json", manifest)]
members.extend((f"files/{key}", value) for key, value in sorted(contents.items()))
members.extend(sorted(tree_objects.items()))
for name, data in members:
entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
entry.compress_type = zipfile.ZIP_STORED
entry.external_attr = 0o100600 << 16
bundle.writestr(entry, data)
result = output.getvalue()
if len(result) > MAX_BUNDLE_BYTES:
raise ValueError("receiver bundle exceeds its size limit")
verify_bundle(result, commit, hashlib.sha256(result).hexdigest())
return result
def write_bundle(repository: Path, commit: str, output_path: Path) -> str:
if output_path.exists() or output_path.is_symlink():
raise ValueError("refusing to overwrite an existing release bundle")
if not output_path.parent.is_dir() or output_path.parent.is_symlink():
raise ValueError("release bundle output directory must already exist and be a real directory")
payload = create_bundle(repository, commit)
with output_path.open("xb") as stream:
stream.write(payload)
stream.flush()
return hashlib.sha256(payload).hexdigest()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path, required=True)
parser.add_argument("--commit", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
digest = write_bundle(args.repository, args.commit, args.output)
print(f"Prepared receiver bundle for {args.commit}")
print(f"Bundle SHA-256: {digest}")
print(f"Bundle path: {args.output.resolve()}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+59
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import base64
import hashlib
import json
import os
@@ -26,6 +27,12 @@ MAX_API_RESPONSE_BYTES = 1024 * 1024
MAX_API_DOWNLOAD_BYTES = 10 * 1024 * 1024
MAX_CANDIDATE_RELEASE_RUNS = 10
API_TIMEOUT_SECONDS = 15
RUNTIME_FILES = (
"deploy/production/compose.yaml",
"deploy/production/Caddyfile",
"infra/deploy/release-policy.json",
)
MAX_RUNTIME_FILE_BYTES = 256 * 1024
def run_api_helper(command: list[str], *, env: dict | None, timeout: int,
@@ -85,6 +92,58 @@ def api_json(path: str, *, helper: str = "gitea-api", env: dict | None = None) -
return value
def repository_file(path: str, commit: str, *, api=api_json) -> bytes:
"""Fetch one small, pinned repository file and verify Gitea's git blob identity."""
if not re.fullmatch(r"[0-9a-f]{40}", commit) or path not in RUNTIME_FILES:
raise ValueError("runtime file request is not allowlisted or commit-pinned")
query = urlencode({"ref": commit})
value = api(f"/repos/{REPOSITORY}/contents/{path}?{query}")
if (value.get("type") != "file" or value.get("path") != path
or value.get("encoding") != "base64"):
raise ValueError("Gitea runtime file metadata is invalid")
encoded, blob_sha, size = value.get("content"), value.get("sha"), value.get("size")
if (not isinstance(encoded, str) or not isinstance(blob_sha, str)
or not re.fullmatch(r"[0-9a-f]{40}", blob_sha)
or isinstance(size, bool) or not isinstance(size, int)
or size < 0 or size > MAX_RUNTIME_FILE_BYTES):
raise ValueError("Gitea runtime file exceeds its limit or has invalid metadata")
try:
content = base64.b64decode(encoded, validate=True)
except (ValueError, base64.binascii.Error) as error:
raise ValueError("Gitea runtime file content is invalid base64") from error
git_blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode("ascii") + b"\0" + content).hexdigest()
if len(content) != size or git_blob_sha != blob_sha:
raise ValueError("Gitea runtime file content does not match its pinned git blob")
return content
def runtime_compatibility_reason(commit: str, *, checkout: Path, policy_file: Path,
api=api_json) -> str | None:
"""Require the bootstrap host's fixed runtime/configuration to match target source."""
for relative in RUNTIME_FILES:
local_path = policy_file if relative == "infra/deploy/release-policy.json" else checkout / relative
try:
info = local_path.lstat()
if not stat.S_ISREG(info.st_mode):
raise ValueError(f"protected runtime file is not a regular file: {relative}")
local_content = local_path.read_bytes()
except OSError as error:
raise ValueError(f"protected runtime file is unavailable: {relative}") from error
remote_content = repository_file(relative, commit, api=api)
if local_content != remote_content:
return "release_policy_changed" if relative.endswith("release-policy.json") else "runtime_config_changed"
return None
def require_runtime_compatibility(commit: str, *, checkout: Path, policy_file: Path,
api=api_json) -> None:
reason = runtime_compatibility_reason(
commit, checkout=checkout, policy_file=policy_file, api=api,
)
if reason is not None:
raise ValueError(f"host {reason.replace('_', ' ')}; reviewed bootstrap update is required")
def latest_release_request(*, current_main: str, api=api_json,
checkout: Path | None = None) -> dict | None:
if not re.fullmatch(r"[0-9a-f]{40}", current_main):
+53
View File
@@ -87,6 +87,59 @@ class DeploymentCutoverTests(unittest.TestCase):
run_mock.assert_not_called()
compose_mock.assert_not_called()
def test_main_advance_after_backup_aborts_before_image_reference_switch(self):
commit = "b" * 40
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "deploy/production").mkdir(parents=True)
for name, payload in (("compose.yaml", b"compose\n"), ("Caddyfile", b"caddy\n")):
(root / "deploy/production" / name).write_bytes(payload)
from hashlib import sha256
deployment_files = {
f"deploy/production/{name}": sha256((root / "deploy/production" / name).read_bytes()).hexdigest()
for name in ("compose.yaml", "Caddyfile")
}
backup = {"images": self.current, "deployment_files": deployment_files}
backup_identity = {
"backup_id": "20261103T000000Z-" + "a" * 32,
"size": 100, "archive_sha256": "c" * 64,
"manifest_sha256": "d" * 64, "manifest": backup,
}
manifest = {
"commit": commit,
"builder": {"name": "Kaniko", "image": deploy_release.BUILDER_IMAGE},
"images": {service: f"registry/{service}@sha256:{'e' * 64}" for service in ("backend", "opensearch")},
}
journal = {"status": "external-backup-verified"}
with (
patch.object(deploy_release.os, "geteuid", return_value=0),
patch.object(deploy_release, "ROOT", root),
patch.object(deploy_release, "STATE_FILE", root / "state.json"),
patch.object(deploy_release, "JOURNAL_FILE", root / "journal.json"),
patch.object(deploy_release, "validate_manifest", return_value=manifest),
patch("release_state.validate_backup_identity", return_value=backup_identity),
patch("release_handoff.latest_main_sha", side_effect=(commit, "f" * 40)),
patch("release_source.require_runtime_compatibility"),
patch.object(deploy_release, "image_ids", return_value=self.current),
patch.object(deploy_release, "app_images", return_value=self.current),
patch.object(deploy_release, "read_json", return_value=journal),
patch.object(deploy_release, "write_json_atomic"),
patch.object(deploy_release, "run", side_effect=(
"", "sha256:" + "1" * 64, "", "sha256:" + "2" * 64,
"sha256:" + "1" * 64, "sha256:" + "2" * 64,
)),
patch.object(deploy_release, "replace_images") as replace,
patch.object(deploy_release, "restore_previous_references"),
patch.object(deploy_release, "compose"),
patch.object(deploy_release, "write_deployed_backend_image"),
):
with self.assertRaisesRegex(ValueError, "cutover failed"):
deploy_release.deploy_external(
manifest, {}, backup, backup_identity, "9" * 64,
self.current, "ru", "ky",
)
replace.assert_not_called()
if __name__ == "__main__":
unittest.main()
+26 -1
View File
@@ -28,7 +28,7 @@ def release_state(commit: str, descriptor: str) -> dict:
class ExternalDeployTests(unittest.TestCase):
def run_execute(self, root: Path, state: dict, *, compose_mock=None):
def run_execute(self, root: Path, state: dict, *, compose_mock=None, compatibility_error=None):
compose_mock = compose_mock or Mock()
state_file = root / "state.json"
state_file.write_text("{}", encoding="utf-8")
@@ -43,6 +43,7 @@ class ExternalDeployTests(unittest.TestCase):
patch.object(external_deploy, "JOURNAL_FILE", journal),
patch.object(external_deploy, "STATE_FILE", state_file),
patch.object(external_deploy, "fetch_release", return_value=({"commit": COMMIT}, b"")),
patch.object(external_deploy, "require_runtime_compatibility", side_effect=compatibility_error),
patch.object(external_deploy, "deployment_lock", return_value=nullcontext()),
patch.object(external_deploy, "image_ids", return_value=CURRENT),
patch.object(external_deploy, "deploy_release_revision", return_value=COMMIT),
@@ -73,6 +74,30 @@ class ExternalDeployTests(unittest.TestCase):
self.assertEqual(journal["status"], "external-backup-pending")
self.assertEqual(journal["commit"], COMMIT)
def test_runtime_mismatch_fails_before_journal_or_production_operations(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
compose = Mock()
with self.assertRaisesRegex(ValueError, "runtime config mismatch"):
self.run_execute(
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
compatibility_error=ValueError("runtime config mismatch"),
)
self.assertFalse((root / "journal.json").exists())
compose.assert_not_called()
def test_runtime_change_after_initial_check_fails_inside_lock_before_stop(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
compose = Mock()
with self.assertRaisesRegex(ValueError, "changed after status"):
self.run_execute(
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
compatibility_error=[None, ValueError("runtime changed after status")],
)
self.assertFalse((root / "journal.json").exists())
compose.assert_not_called()
if __name__ == "__main__":
unittest.main()
+36 -7
View File
@@ -8,7 +8,7 @@ import external_status
class ExternalStatusTests(unittest.TestCase):
def test_status_is_candidate_only_after_release_descriptor_verification(self):
def test_status_is_prepared_only_after_release_descriptor_verification(self):
commit = "a" * 40
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
with tempfile.TemporaryDirectory() as directory:
@@ -19,15 +19,19 @@ class ExternalStatusTests(unittest.TestCase):
policy.write_text("{}", encoding="utf-8")
with patch.object(external_status.os, "geteuid", return_value=0), \
patch.object(external_status, "latest_main_sha", return_value=commit), \
patch.object(external_status, "runtime_compatibility_reason", return_value=None) as compatible, \
patch.object(external_status, "latest_release_request", return_value=request) as select, \
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
value = external_status.status_document(
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
state_file=state, main_lookup=lambda _path: commit,
checkout_lookup=lambda _path: commit,
)
self.assertEqual(value["state"], "candidate")
self.assertEqual(value["state"], "prepared")
self.assertEqual(value["release"], request)
compatible.assert_called_once()
self.assertEqual(compatible.call_args.args, (commit,))
self.assertEqual(compatible.call_args.kwargs["checkout"], root)
self.assertEqual(compatible.call_args.kwargs["policy_file"], policy)
select.assert_called_once_with(current_main=commit, checkout=root, api=ANY)
verify.assert_called_once()
@@ -46,17 +50,38 @@ class ExternalStatusTests(unittest.TestCase):
"current": images, "previous": images,
}), encoding="utf-8")
with patch.object(external_status.os, "geteuid", return_value=0), \
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
patch.object(external_status, "latest_release_request", return_value=request), \
patch.object(external_status, "fetch_release") as verify:
result = external_status.status_document(
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
state_file=state, main_lookup=lambda _path: commit,
checkout_lookup=lambda _path: commit,
)
self.assertEqual(result["reason"], "already_applied")
verify.assert_not_called()
def test_status_fails_closed_for_root_guard_recovery_and_stale_checkout(self):
def test_status_does_not_publish_prepared_tuple_if_main_advances_during_verification(self):
commit = "a" * 40
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
policy = root / "policy.json"
policy.write_text("{}", encoding="utf-8")
with patch.object(external_status.os, "geteuid", return_value=0), \
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
patch.object(external_status, "latest_release_request", return_value=request), \
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
mains = iter((commit, "c" * 40))
result = external_status.status_document(
checkout=root, journal=root / "journal", policy_file=policy,
state_file=root / "state.json", main_lookup=lambda _path: next(mains),
)
self.assertEqual(result, {
"schema_version": 1, "state": "not_ready", "reason": "main_advanced", "release": None,
})
verify.assert_called_once()
def test_status_fails_closed_for_root_guard_recovery_and_runtime_mismatch(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
journal = root / "recovery.json"
@@ -70,11 +95,15 @@ class ExternalStatusTests(unittest.TestCase):
value = external_status.status_document(checkout=root, journal=journal, policy_file=policy)
self.assertEqual(value["reason"], "recovery_journal")
journal.unlink()
with patch.object(external_status.os, "geteuid", return_value=0), \
patch.object(external_status, "runtime_compatibility_reason", return_value="runtime_config_changed"), \
patch.object(external_status, "latest_release_request") as select:
value = external_status.status_document(
checkout=root, journal=journal, policy_file=policy,
main_lookup=lambda _path: "a" * 40, checkout_lookup=lambda _path: "c" * 40,
main_lookup=lambda _path: "a" * 40,
)
self.assertEqual(value["reason"], "host_checkout_stale")
self.assertEqual(value["reason"], "runtime_config_changed")
select.assert_not_called()
if __name__ == "__main__":
+79
View File
@@ -1,4 +1,5 @@
import hashlib
import base64
import json
import stat
import sys
@@ -49,6 +50,84 @@ def write_artifact(path, manifest=MANIFEST, extra=()):
class ReleaseSourceTests(unittest.TestCase):
def test_runtime_files_allow_bootstrap_a_to_release_b_only_when_configs_match(self):
commit_b = "f" * 40
contents = {
"deploy/production/compose.yaml": b"compose-v1\n",
"deploy/production/Caddyfile": b"caddy-v1\n",
"infra/deploy/release-policy.json": b"policy-v1\n",
}
def response(path):
payload = contents[path]
blob_sha = hashlib.sha1(b"blob " + str(len(payload)).encode() + b"\0" + payload).hexdigest()
return {"type": "file", "path": path, "encoding": "base64",
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": blob_sha}
def api(request):
self.assertIn(f"?ref={commit_b}", request)
path = request.split("/contents/", 1)[1].split("?", 1)[0]
return response(path)
with tempfile.TemporaryDirectory() as directory:
checkout = Path(directory)
(checkout / "deploy/production").mkdir(parents=True)
(checkout / "deploy/production/compose.yaml").write_bytes(contents["deploy/production/compose.yaml"])
(checkout / "deploy/production/Caddyfile").write_bytes(contents["deploy/production/Caddyfile"])
policy = checkout / "policy.json"
policy.write_bytes(contents["infra/deploy/release-policy.json"])
self.assertIsNone(release_source.runtime_compatibility_reason(
commit_b, checkout=checkout, policy_file=policy, api=api,
))
for relative in ("deploy/production/compose.yaml", "deploy/production/Caddyfile"):
local_path = checkout / relative
expected = contents[relative]
local_path.write_bytes(b"changed\n")
self.assertEqual(release_source.runtime_compatibility_reason(
commit_b, checkout=checkout, policy_file=policy, api=api,
), "runtime_config_changed")
local_path.write_bytes(expected)
policy.write_bytes(b"policy-changed\n")
self.assertEqual(release_source.runtime_compatibility_reason(
commit_b, checkout=checkout, policy_file=policy, api=api,
), "release_policy_changed")
def test_runtime_file_rejects_forged_git_blob_identity(self):
payload = b"trusted content\n"
response = {"type": "file", "path": "deploy/production/compose.yaml", "encoding": "base64",
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": "a" * 40}
with self.assertRaisesRegex(ValueError, "does not match"):
release_source.repository_file(
"deploy/production/compose.yaml", COMMIT,
api=lambda _path: response,
)
def test_runtime_compatibility_rejects_local_symlink_even_when_bytes_match(self):
content = b"protected\n"
with tempfile.TemporaryDirectory() as directory:
checkout = Path(directory)
config_dir = checkout / "deploy/production"
config_dir.mkdir(parents=True)
(config_dir / "compose.yaml").write_bytes(content)
(config_dir / "Caddyfile").write_bytes(content)
policy = checkout / "policy.json"
policy.write_bytes(content)
external = checkout / "outside.yaml"
external.write_bytes(content)
(config_dir / "Caddyfile").unlink()
(config_dir / "Caddyfile").symlink_to(external)
blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest()
with self.assertRaisesRegex(ValueError, "not a regular file"):
release_source.runtime_compatibility_reason(
COMMIT, checkout=checkout, policy_file=policy,
api=lambda _path: {
"type": "file", "path": "deploy/production/compose.yaml",
"encoding": "base64", "content": base64.b64encode(content).decode(),
"size": len(content), "sha": blob_sha,
},
)
def test_verify_run_binds_run_id_workflow_path_event_branch_commit_and_main(self):
def api(path):
if path.endswith("/actions/runs/301"):
@@ -0,0 +1,289 @@
param(
[Parameter(Mandatory = $true)]
[string]$PythonPath
)
$ErrorActionPreference = 'Stop'
$installerPath = Join-Path $PSScriptRoot 'install_windows_release_client.ps1'
$fixtureScript = Join-Path $PSScriptRoot 'test_windows_release_launcher.py'
$temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$temporaryBase = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $temporaryBase).Path).TrimEnd('\')
$temporaryName = 'akyldash-bundle-mode-' + [guid]::NewGuid().ToString('N')
$temporaryRoot = [IO.Path]::Combine($temporaryBase, $temporaryName)
if (Test-Path -LiteralPath $temporaryRoot) { throw 'Temporary fixture directory already exists.' }
[void][IO.Directory]::CreateDirectory($temporaryRoot)
$temporaryRootItem = Get-Item -LiteralPath $temporaryRoot -Force
if (($temporaryRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Temporary fixture root must not be a reparse point.'
}
$temporaryRootCreated = $temporaryRootItem.CreationTimeUtc
$temporaryOwnerSid = (Get-Acl -LiteralPath $temporaryRoot).GetOwner(
[Security.Principal.SecurityIdentifier]).Value
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
if ($temporaryOwnerSid -cne $currentSid) { throw 'Temporary fixture root is not owned by the current user.' }
$bundlePath = Join-Path $temporaryRoot 'fixture.zip'
function Get-VerifiedFixtureRoot([string]$Candidate) {
$expectedRoot = [IO.Path]::Combine($temporaryBase, $temporaryName)
$candidatePath = [IO.Path]::GetFullPath($Candidate)
if ([string]::Compare($candidatePath, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0) {
throw 'Refusing a fixture root outside the exact temporary base/name.'
}
$item = Get-Item -LiteralPath $candidatePath -Force
if ($item -isnot [IO.DirectoryInfo] -or
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Fixture root must remain a real directory, not a reparse point.'
}
$resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $candidatePath).Path).TrimEnd('\')
if ([string]::Compare($resolved, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0 -or
[string]::Compare([IO.Path]::GetDirectoryName($resolved), $temporaryBase,
[StringComparison]::OrdinalIgnoreCase) -ne 0 -or
[string]::Compare([IO.Path]::GetFileName($resolved), $temporaryName,
[StringComparison]::OrdinalIgnoreCase) -ne 0 -or
$item.CreationTimeUtc -ne $temporaryRootCreated) {
throw 'Fixture root was replaced, moved, or resolved outside its original identity.'
}
$ownerSid = (Get-Acl -LiteralPath $candidatePath).GetOwner(
[Security.Principal.SecurityIdentifier]).Value
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
throw 'Fixture root owner changed.'
}
return $resolved
}
function Get-VerifiedFixtureEntry([string]$Path, [string]$ResolvedRoot) {
$item = Get-Item -LiteralPath $Path -Force
if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw 'Fixture entry must be a regular file or directory without reparse points.'
}
$resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $item.FullName).Path)
if (-not $resolved.StartsWith($ResolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
throw 'Fixture entry resolved outside its verified temporary root.'
}
$ownerSid = (Get-Acl -LiteralPath $resolved).GetOwner(
[Security.Principal.SecurityIdentifier]).Value
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
throw 'Fixture entry is not owned by the current user.'
}
return Get-Item -LiteralPath $resolved -Force
}
$wrongRootRejected = $false
try { [void](Get-VerifiedFixtureRoot -Candidate $temporaryBase) }
catch { $wrongRootRejected = $true }
if (-not $wrongRootRejected) { throw 'Fixture cleanup accepted an incorrect root path.' }
$passMessage = $null
try {
$fixtureOutput = & $PythonPath -B -E -s -S $fixtureScript --write-bundle-fixture $bundlePath
if ($LASTEXITCODE -ne 0) { throw 'The temporary packager fixture failed.' }
$fixture = ($fixtureOutput -join "`n") | ConvertFrom-Json
$gitObjectDirectory = Join-Path $temporaryRoot 'fixture-repository\.git\objects'
$readonlyObject = Get-ChildItem -LiteralPath $gitObjectDirectory -File -Force -Recurse | Select-Object -First 1
if ($null -eq $readonlyObject) { throw 'Packager fixture did not create a loose Git object.' }
$verifiedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
$verifiedObject = Get-VerifiedFixtureEntry -Path $readonlyObject.FullName -ResolvedRoot $verifiedRoot
[IO.File]::SetAttributes($verifiedObject.FullName,
[IO.FileAttributes]([int]$verifiedObject.Attributes -bor [int][IO.FileAttributes]::ReadOnly))
$tokens = $null
$parseErrors = $null
$installerAst = [System.Management.Automation.Language.Parser]::ParseFile(
$installerPath, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count -gt 0) { throw 'Installer PowerShell syntax is invalid.' }
$validatorAst = $installerAst.Find({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -ceq 'Assert-ReceiverBundleEntryMode'
}, $true)
if ($null -eq $validatorAst) { throw 'Installer bundle-mode validator was not found.' }
. ([scriptblock]::Create($validatorAst.Extent.Text))
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$mutationRights = [System.Security.AccessControl.FileSystemRights](
[int][System.Security.AccessControl.FileSystemRights]::WriteData -bor
[int][System.Security.AccessControl.FileSystemRights]::AppendData -bor
[int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
[int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
)
$pathReplacementRights = [System.Security.AccessControl.FileSystemRights](
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
)
foreach ($guardName in @('Assert-ProtectedPath', 'Assert-TrustedPath')) {
$guardAst = $installerAst.Find({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -ceq $guardName
}, $true)
if ($null -eq $guardAst) { throw "Installer ACL guard was not found: $guardName" }
. ([scriptblock]::Create($guardAst.Extent.Text))
}
$unknownSid = [Security.Principal.SecurityIdentifier]::new(
'S-1-5-21-3141592653-2718281828-1618033988-987654')
$unmapped = $false
try { $null = $unknownSid.Translate([Security.Principal.NTAccount]) }
catch [Security.Principal.IdentityNotMappedException] { $unmapped = $true }
if (-not $unmapped) { throw 'The synthetic ACL SID unexpectedly resolves to an NTAccount.' }
$savedMockAcl = $script:akyldashAclFixture
function Get-Acl([string]$LiteralPath) { return $script:akyldashAclFixture }
try {
$sidSystem = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
$sidAdmins = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
$privateFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
$privateFixtureAcl.SetAccessRuleProtection($true, $false)
$privateFixtureAcl.SetOwner($identity.User)
foreach ($sid in @($sidSystem, $sidAdmins, $identity.User)) {
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
$sid, [System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Allow))
}
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
$unknownSid, [System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Allow))
$script:akyldashAclFixture = $privateFixtureAcl
$privateRejected = $false
try { Assert-ProtectedPath -Path $temporaryRoot -AllowOwnerWrite $true }
catch {
if ($_.Exception.Message -notlike 'Private state path ACL must grant only full control*') { throw }
$privateRejected = $true
}
if (-not $privateRejected) { throw 'Unmapped SID was not rejected by the protected-path guard.' }
$trustedFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
$trustedFixtureAcl.SetAccessRuleProtection($true, $false)
$trustedFixtureAcl.SetOwner($sidSystem)
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
$sidSystem, [System.Security.AccessControl.FileSystemRights]::FullControl,
[System.Security.AccessControl.AccessControlType]::Allow))
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
$unknownSid, [System.Security.AccessControl.FileSystemRights]::WriteData,
[System.Security.AccessControl.AccessControlType]::Allow))
$script:akyldashAclFixture = $trustedFixtureAcl
$trustedRejected = $false
try { Assert-TrustedPath -Path $temporaryRoot }
catch {
if ($_.Exception.Message -notlike 'Protected path grants replacement/write access*') { throw }
$trustedRejected = $true
}
if (-not $trustedRejected) { throw 'Unmapped SID was not rejected by the trusted-path guard.' }
} finally {
Remove-Item Function:\Get-Acl -ErrorAction SilentlyContinue
if ($null -eq $savedMockAcl) { Remove-Variable -Name akyldashAclFixture -Scope Script -ErrorAction SilentlyContinue }
else { $script:akyldashAclFixture = $savedMockAcl }
}
Add-Type -AssemblyName System.IO.Compression
$stream = [IO.File]::OpenRead($bundlePath)
try {
$archive = [System.IO.Compression.ZipArchive]::new(
$stream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
try {
foreach ($entry in $archive.Entries) {
Assert-ReceiverBundleEntryMode -Entry $entry
}
} finally { $archive.Dispose() }
} finally { $stream.Dispose() }
$alteredPath = Join-Path $temporaryRoot 'altered-mode.zip'
$sourceStream = [IO.File]::OpenRead($bundlePath)
$targetStream = [IO.File]::Create($alteredPath)
try {
$sourceArchive = [System.IO.Compression.ZipArchive]::new(
$sourceStream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
$targetArchive = [System.IO.Compression.ZipArchive]::new(
$targetStream, [System.IO.Compression.ZipArchiveMode]::Create, $true)
try {
$changed = $false
foreach ($entry in $sourceArchive.Entries) {
$copy = $targetArchive.CreateEntry($entry.FullName, [System.IO.Compression.CompressionLevel]::NoCompression)
$copy.ExternalAttributes = $entry.ExternalAttributes
if (-not $changed) {
$copy.ExternalAttributes = [int]-2120220672
$changed = $true
}
$entryInput = $entry.Open()
$entryOutput = $copy.Open()
try { $entryInput.CopyTo($entryOutput) }
finally { $entryOutput.Dispose(); $entryInput.Dispose() }
}
if (-not $changed) { throw 'Packager fixture had no entries to mutate.' }
} finally { $targetArchive.Dispose(); $sourceArchive.Dispose() }
} finally { $targetStream.Dispose(); $sourceStream.Dispose() }
$alteredStream = [IO.File]::OpenRead($alteredPath)
try {
$alteredArchive = [System.IO.Compression.ZipArchive]::new(
$alteredStream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
try {
$alteredEntry = $alteredArchive.Entries[0]
$alteredMode = (([long]$alteredEntry.ExternalAttributes -shr 16) -band 0xFFFF)
if ($alteredMode -ne 0x81A0) { throw 'Altered ZIP fixture does not contain the intended unsafe mode.' }
$rejected = $false
try { Assert-ReceiverBundleEntryMode -Entry $alteredEntry }
catch {
if ($_.Exception.Message -notmatch 'unsafe file mode') { throw }
$rejected = $true
}
if (-not $rejected) { throw 'Installer validator accepted an altered ZIP entry mode.' }
} finally { $alteredArchive.Dispose() }
} finally { $alteredStream.Dispose() }
$passMessage = ("PASS: installer validator accepted the actual packager bundle for commit {0} (SHA-256 {1}) and rejected altered mode; read-only Git object cleanup succeeded." -f $fixture.commit, $fixture.sha256)
} finally {
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
$entries = @(Get-ChildItem -LiteralPath $resolvedRoot -Force -Recurse)
$verifiedEntries = @(
foreach ($entry in $entries) {
Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
}
)
foreach ($entry in ($verifiedEntries | Sort-Object { $_.FullName.Length } -Descending)) {
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
$verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
$writableAttributes = [IO.FileAttributes]([int]$verified.Attributes -band
(-bnot [int][IO.FileAttributes]::ReadOnly))
[IO.File]::SetAttributes($verified.FullName, $writableAttributes)
$verified = Get-VerifiedFixtureEntry -Path $verified.FullName -ResolvedRoot $resolvedRoot
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
throw 'Could not clear the read-only attribute from the verified fixture entry.'
}
}
}
$remainingRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
$remainingEntries = @(Get-ChildItem -LiteralPath $remainingRoot -Force -Recurse)
$initialPaths = @($entries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object)
$remainingPaths = @($remainingEntries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object)
if (($initialPaths -join "`n") -cne ($remainingPaths -join "`n")) {
throw 'Temporary fixture contents changed during cleanup; refusing deletion.'
}
foreach ($entry in ($remainingEntries | Sort-Object { $_.FullName.Length } -Descending)) {
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
$verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
throw 'Refusing fixture deletion while a verified entry remains read-only.'
}
if ($verified -is [IO.DirectoryInfo]) {
[IO.Directory]::Delete($verified.FullName, $false)
} elseif ($verified -is [IO.FileInfo]) {
[IO.File]::Delete($verified.FullName)
} else {
throw 'Refusing to delete an unknown temporary fixture entry type.'
}
}
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
[IO.Directory]::Delete($resolvedRoot, $false)
}
Write-Output $passMessage
@@ -0,0 +1,415 @@
import os
import re
import shlex
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
import zipfile
import io
import hashlib
import json
from pathlib import Path
import prepare_windows_release_bundle as release_bundle
from windows_release_launcher import WINDOWS_PATH, WINDOWS_TOOLS, configure_windows_tool_environment
ROOT = Path(__file__).resolve().parents[2]
class WindowsReleaseBootstrapTests(unittest.TestCase):
def _installer_mutation_mask(self):
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
match = re.search(r"\$mutationRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S)
self.assertIsNotNone(match, "installer must declare one explicit FileSystemRights mutation mask")
names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1)))
values = {
"WriteData": 0x0002,
"AppendData": 0x0004,
"WriteExtendedAttributes": 0x0010,
"WriteAttributes": 0x0100,
"DeleteSubdirectoriesAndFiles": 0x0040,
"Delete": 0x10000,
"ChangePermissions": 0x40000,
"TakeOwnership": 0x80000,
}
self.assertEqual(names, set(values), "mutation mask must name each mutating filesystem right explicitly")
mask = 0
for name in names:
mask |= values[name]
return script, mask, values
def test_installer_acl_guard_rejects_each_mutation_right(self):
script, mask, rights = self._installer_mutation_mask()
trusted = {"S-1-5-18", "S-1-5-32-544", "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"}
def safe_acl(owner, entries):
if owner not in trusted:
return False
for sid, access_type, granted, inherit_only in entries:
if inherit_only:
continue
if access_type == "Allow" and granted & mask and sid not in trusted:
return False
return True
for name, right in rights.items():
with self.subTest(right=name):
self.assertNotEqual(mask & right, 0)
self.assertFalse(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", right, False)]))
self.assertTrue(safe_acl(
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
[("S-1-5-32-544", "Allow", mask, False), ("S-1-5-11", "Allow", 0x0001, False)],
))
self.assertTrue(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", mask, True)]))
self.assertIn("([int]$_.FileSystemRights -band [int]$mutationRights)", script)
self.assertIn("([int]$rule.FileSystemRights -band [int]$checkedRights)", script)
self.assertIn("PropagationFlags]::InheritOnly", script)
owner_branch = re.search(
r"if \(\$AllowOwnerWrite\) \{\s*\$approvedOwners = @\((.*?)\)\s*\} else \{\s*\$approvedOwners = @\((.*?)\)\s*\}",
script, re.S,
)
self.assertIsNotNone(owner_branch)
self.assertIn("$identity.User.Value", owner_branch.group(1))
self.assertNotIn("$identity.User.Value", owner_branch.group(2))
self.assertIn("$trustedInstaller", owner_branch.group(2))
def test_parent_path_guard_allows_create_sibling_but_rejects_replacement_rights(self):
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
match = re.search(r"\$pathReplacementRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S)
self.assertIsNotNone(match)
names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1)))
values = {"DeleteSubdirectoriesAndFiles": 0x40, "Delete": 0x10000,
"ChangePermissions": 0x40000, "TakeOwnership": 0x80000}
self.assertEqual(names, set(values))
mask = sum(values.values())
self.assertEqual(mask & 0x0004, 0) # AppendData/CreateDirectories on C:\ creates a sibling.
for right in values.values():
self.assertNotEqual(mask & right, 0)
def test_installer_source_is_hash_bound_locked_and_never_runs_git(self):
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
self.assertIn("[IO.FileShare]::Read", script)
self.assertIn("[System.IO.Compression.ZipArchive]::new($bundleStream", script)
self.assertIn("Assert-ReceiverBundleEntryMode -Entry $zipEntry", script)
self.assertIn("$entryMode -ne 0x8180", script)
self.assertIn("$bundleHash -ne $ExpectedBundleSha256", script)
self.assertIn("manifest.commit -cne $ExpectedCommit", script)
self.assertIn("Get-GitObjectHex -Kind 'blob' -Bytes $data", script)
self.assertIn("Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes", script)
self.assertIn("Get-GitObjectHex -Kind 'tree' -Bytes $treeData", script)
self.assertIn("Receiver bundle commit tree does not contain the exact source file", script)
self.assertIn("objects/commit.bin", script)
self.assertIn("objects/root-tree.bin", script)
self.assertIn("$treeHeaders[0].Groups[1].Value -cne $seenTrees['']", script)
self.assertIn("$verifiedFiles[$file.path] = $data", script)
self.assertNotIn("(& $git", script)
self.assertNotIn("Start-Process", script)
self.assertNotIn("Copy-Item", script)
self.assertLess(script.index("$bundleHash -ne $ExpectedBundleSha256"),
script.index("[IO.File]::WriteAllBytes($destination, $verifiedFiles[$name])"))
self.assertLess(script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }"),
script.index("$bundleStream = [IO.File]::Open"))
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script)
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)", script)
self.assertIn("$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value", script)
self.assertGreaterEqual(script.count("$acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])"), 2)
self.assertIn("$rule.IdentityReference.Value", script)
self.assertNotIn(".Translate(", script)
self.assertIn("Assert-TrustedPath $appRoot", script)
self.assertIn("Assert-TrustedPath $programData -ParentsOnly $true", script)
self.assertIn("Set-TrustedCodeOwner $app", script)
self.assertIn("Set-TrustedCodeOwner $destination", script)
for name in release_bundle.FILES:
self.assertIn(f"files/{name}", script)
def test_windows_acl_harness_exercises_unmapped_sid_with_real_installer_guards(self):
harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8")
self.assertIn("Assert-ProtectedPath", harness)
self.assertIn("Assert-TrustedPath", harness)
self.assertIn("IdentityNotMappedException", harness)
self.assertIn("$unknownSid", harness)
self.assertIn("$guardAst.Extent.Text", harness)
self.assertIn("Unmapped SID was not rejected by the protected-path guard", harness)
def test_release_bundle_uses_exact_commit_objects_not_dirty_worktree_or_git_hooks(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
repository = root / "repo"
repository.mkdir()
self._git(repository, "init", "-b", "main")
self._git(repository, "config", "user.name", "Fixture")
self._git(repository, "config", "user.email", "fixture@example.invalid")
for name, relative in release_bundle.FILES.items():
path = repository / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(f"committed:{name}\n".encode())
self._git(repository, "add", "--", *release_bundle.FILES.values())
self._git(repository, "commit", "-m", "fixture")
commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
self._git(repository, "switch", "-c", "not-main")
self._git(repository, "commit", "--allow-empty", "-m", "alternate")
alternate = self._git(repository, "rev-parse", "HEAD").decode().strip()
self._git(repository, "switch", "main")
with self.assertRaisesRegex(ValueError, "local main branch tip"):
release_bundle.create_bundle(repository, alternate)
executable_path = repository / release_bundle.FILES["archive.py"]
self._git(repository, "switch", "-c", "executable-mode")
executable_path.chmod(0o755)
self._git(repository, "add", "--", release_bundle.FILES["archive.py"])
self._git(repository, "commit", "-m", "executable source")
executable_commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
self._git(repository, "update-ref", "refs/heads/main", executable_commit)
with self.assertRaisesRegex(ValueError, "not a regular Git blob"):
release_bundle.create_bundle(repository, executable_commit)
self._git(repository, "update-ref", "refs/heads/main", commit)
self._git(repository, "switch", "main")
symlink_path = repository / release_bundle.FILES["archive.py"]
self._git(repository, "switch", "-c", "symlink-source")
symlink_path.unlink()
symlink_path.symlink_to("missing-target")
self._git(repository, "add", "--", release_bundle.FILES["archive.py"])
self._git(repository, "commit", "-m", "symlink source")
symlink_commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
self._git(repository, "update-ref", "refs/heads/main", symlink_commit)
with self.assertRaisesRegex(ValueError, "not a regular Git blob"):
release_bundle.create_bundle(repository, symlink_commit)
self._git(repository, "update-ref", "refs/heads/main", commit)
self._git(repository, "switch", "main")
edited = repository / release_bundle.FILES["receive_external.py"]
edited.write_bytes(b"uncommitted attacker-controlled edit\n")
hook_marker = root / "fsmonitor-ran"
hook = root / "fsmonitor.sh"
hook.write_text(f"#!/bin/sh\nprintf ran > {shlex.quote(str(hook_marker))}\n", encoding="utf-8")
hook.chmod(0o700)
self._git(repository, "config", "core.fsmonitor", str(hook))
payload = release_bundle.create_bundle(repository, commit)
expected_sha256 = hashlib.sha256(payload).hexdigest()
self.assertEqual(set(release_bundle.verify_bundle(payload, commit, expected_sha256)),
set(release_bundle.FILES))
self.assertFalse(hook_marker.exists(), "bundle creation must not invoke config-driven fsmonitor hooks")
with zipfile.ZipFile(io.BytesIO(payload)) as bundle:
manifest = json.loads(bundle.read("manifest.json"))
self.assertEqual(manifest["commit"], commit)
self.assertEqual(set(member.filename for member in bundle.infolist()),
{"manifest.json", *(f"files/{name}" for name in release_bundle.FILES),
*release_bundle.TREE_OBJECTS.values(), "objects/commit.bin"})
self.assertEqual({(member.external_attr >> 16) & 0xFFFF for member in bundle.infolist()},
{0o100600})
self.assertEqual(release_bundle._git_object_sha("commit", bundle.read("objects/commit.bin")), commit)
for tree in manifest["trees"]:
object_name = release_bundle.TREE_OBJECTS[tree["path"]]
self.assertEqual(release_bundle._git_object_sha("tree", bundle.read(object_name)), tree["sha1"])
for name in release_bundle.FILES:
self.assertEqual(bundle.read(f"files/{name}"), f"committed:{name}\n".encode())
for member in ("objects/commit.bin", "objects/infra-tree.bin", "files/receive_external.py"):
with self.subTest(member=member):
corrupted = self._rewrite_bundle(payload, member, lambda content: content[:-1] + bytes([content[-1] ^ 1]))
with self.assertRaises(ValueError):
release_bundle.verify_bundle(corrupted, commit, hashlib.sha256(corrupted).hexdigest())
traversal = self._rewrite_bundle(payload, "../escape", lambda _unused: b"outside")
with self.assertRaisesRegex(ValueError, "unexpected entries"):
release_bundle.verify_bundle(traversal, commit, hashlib.sha256(traversal).hexdigest())
duplicate = self._rewrite_bundle(payload, "manifest.json", lambda content: content, duplicate=True)
with self.assertRaisesRegex(ValueError, "duplicate"):
release_bundle.verify_bundle(duplicate, commit, hashlib.sha256(duplicate).hexdigest())
symlink_entry = self._rewrite_bundle(payload, "files/archive.py", lambda content: content,
external_attr=0o120777 << 16)
with self.assertRaisesRegex(ValueError, "unsafe mode"):
release_bundle.verify_bundle(symlink_entry, commit, hashlib.sha256(symlink_entry).hexdigest())
altered_mode = self._rewrite_bundle(payload, "files/archive.py", lambda content: content,
external_attr=0o100755 << 16)
with self.assertRaisesRegex(ValueError, "unsafe mode"):
release_bundle.verify_bundle(altered_mode, commit, hashlib.sha256(altered_mode).hexdigest())
with self.assertRaisesRegex(ValueError, "invalid size"):
release_bundle.verify_bundle(b"x" * (release_bundle.MAX_BUNDLE_BYTES + 1), commit, "0" * 64)
@staticmethod
def _git(repository, *arguments):
return subprocess.run(["git", "-C", str(repository), *arguments], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
@staticmethod
def _rewrite_bundle(payload, target, transform, *, duplicate=False, external_attr=None):
output = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(payload)) as original, zipfile.ZipFile(output, "w") as altered:
for item in original.infolist():
content = original.read(item)
if item.filename == target:
content = transform(content)
info = zipfile.ZipInfo(item.filename, item.date_time)
info.compress_type = zipfile.ZIP_STORED
info.external_attr = external_attr if item.filename == target and external_attr is not None else item.external_attr
altered.writestr(info, content)
if duplicate and item.filename == target:
altered.writestr(info, content)
if target not in {item.filename for item in original.infolist()}:
info = zipfile.ZipInfo(target)
info.compress_type = zipfile.ZIP_STORED
info.external_attr = 0o100600 << 16
altered.writestr(info, transform(b""))
return output.getvalue()
def test_installer_has_no_child_processes_and_checks_tools_before_bundle_read(self):
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
trusted_tools = script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }")
bundle_open = script.index("$bundleStream = [IO.File]::Open")
self.assertLess(trusted_tools, bundle_open)
self.assertNotIn("(& $git", script)
self.assertNotIn("Start-Process", script)
self.assertNotIn("Process]::Start", script)
self.assertIn("while ($null -ne $current)", script)
self.assertIn("$current = $current.Parent", script)
self.assertIn("$ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script)
def test_windows_bundle_mode_harness_uses_safe_python_and_validates_cleanup_target(self):
harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8")
self.assertIn("-B -E -s -S", harness)
self.assertIn("$item.CreationTimeUtc -ne $temporaryRootCreated", harness)
self.assertIn("$ownerSid -cne $temporaryOwnerSid", harness)
self.assertIn("[IO.FileAttributes]::ReparsePoint", harness)
self.assertIn("[IO.Directory]::Delete($resolvedRoot, $false)", harness)
self.assertNotIn("[IO.Directory]::Delete($resolvedRoot, $true)", harness)
self.assertIn("$wrongRootRejected", harness)
self.assertIn("$readonlyObject", harness)
self.assertIn("-bnot [int][IO.FileAttributes]::ReadOnly", harness)
self.assertIn("[IO.File]::Delete($verified.FullName)", harness)
self.assertIn("-not $resolved.StartsWith($ResolvedRoot + '\\'", harness)
self.assertLess(harness.index("if ($alteredMode -ne 0x81A0)"),
harness.index("Assert-ReceiverBundleEntryMode -Entry $alteredEntry"))
def test_windows_tool_environment_discards_path_and_git_overrides(self):
environment = {
"PATH": r"C:\Users\attacker\bin",
"PATHEXT": ".COM;.EXE;.BAT",
"PSModulePath": r"C:\Users\attacker\modules",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "core.sshCommand",
"GIT_CONFIG_VALUE_0": "attacker.exe",
"SAFE_SETTING": "preserved",
}
calls = []
def which(command):
calls.append(command)
return str(WINDOWS_TOOLS[command])
configure_windows_tool_environment(environ=environment, which=which)
self.assertEqual(environment["PATH"], WINDOWS_PATH)
self.assertEqual(environment["PATHEXT"], ".EXE")
self.assertEqual(environment["SystemRoot"], r"C:\Windows")
self.assertEqual(environment["PSModulePath"], r"C:\Windows\System32\WindowsPowerShell\v1.0\Modules")
self.assertEqual(environment["SAFE_SETTING"], "preserved")
self.assertFalse(any(key.upper().startswith("GIT_") for key in environment))
self.assertEqual(set(calls), set(WINDOWS_TOOLS))
def test_windows_tool_environment_rejects_unexpected_resolution(self):
environment = {"PATH": r"C:\attacker"}
with self.assertRaisesRegex(RuntimeError, "protected path"):
configure_windows_tool_environment(environ=environment, which=lambda _command: r"C:\attacker\ssh.exe")
def test_launcher_ignores_poisoned_working_directory_and_pythonpath(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
app = root / "Program Files" / "Akyldash"
poison = root / "poison"
app.mkdir(parents=True)
poison.mkdir()
launcher = app / "windows_release_launcher.py"
launcher.write_bytes((ROOT / "infra/deploy/windows_release_launcher.py").read_bytes())
(app / "receive_external.py").write_text("def main(): return 0\n", encoding="utf-8")
(app / "archive.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
(app / "external_session.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
marker = root / "sitecustomize-ran"
(poison / "json.py").write_text("raise RuntimeError('poison json imported')\n", encoding="utf-8")
(poison / "sitecustomize.py").write_text(
"import os\nfrom pathlib import Path\nPath(os.environ['POISON_MARKER']).touch()\n",
encoding="utf-8",
)
driver = app / "driver.py"
driver.write_text(
"import sys\n"
"from windows_release_launcher import configure_import_path\n"
"configure_import_path(executable=sys.executable, app_dir=__import__('pathlib').Path(__file__).parent, "
"python_root=__import__('pathlib').Path(sys.prefix), expected_executable=__import__('pathlib').Path(sys.executable), "
"protected_app_root=__import__('pathlib').Path(__file__).parents[1])\n"
"import json, receive_external, archive, external_session\n"
"from pathlib import Path\n"
"assert Path(json.__file__).resolve().is_relative_to(Path(sys.prefix).resolve())\n"
"assert Path(receive_external.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
"assert Path(archive.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
"assert Path(external_session.__file__).resolve().parent == Path(__file__).parent.resolve()\n",
encoding="utf-8",
)
environment = {**os.environ, "PYTHONPATH": str(poison), "POISON_MARKER": str(marker)}
result = subprocess.run(
[sys.executable, "-E", "-s", "-S", str(driver)], cwd=poison,
env=environment, text=True, capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(marker.exists())
def test_disabled_task_uses_pinned_python_and_hardened_launcher(self):
path = ROOT / "infra/deploy/windows-release-trigger.example.xml"
tree = ET.parse(path)
namespace = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
task = tree.getroot()
self.assertEqual(task.findtext("t:Settings/t:Enabled", namespaces=namespace), "false")
trigger = task.find("t:Triggers/t:CalendarTrigger", namespace)
self.assertEqual(trigger.findtext("t:Enabled", namespaces=namespace), "false")
self.assertEqual(task.findtext("t:Actions/t:Exec/t:Command", namespaces=namespace), r"C:\Python313\python.exe")
arguments = task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=namespace)
self.assertIn("-B -E -s -S", arguments)
self.assertIn("windows_release_launcher.py", arguments)
def test_installer_never_registers_or_enables_task_and_uses_canonical_trigger(self):
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
self.assertIn("release-trigger.json", script)
self.assertIn("release-auto-apply.json", script)
self.assertIn('"enabled":false', script)
self.assertIn("C:\\Python313\\python.exe", script)
self.assertIn("Get-AuthenticodeSignature", script)
self.assertIn(".Status -ne 'Valid'", script)
self.assertIn("client-$ExpectedCommit", script)
self.assertIn("--auto-apply-prepared", script)
self.assertIn("Akyldash-ReleaseAutoApply-disabled.xml", script)
self.assertIn("Assert-TrustedPath $tool", script)
self.assertIn(r"$git = 'C:\Program Files\Git\cmd\git.exe'", script)
self.assertIn(r"$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'", script)
self.assertIn(r"$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'", script)
self.assertIn("$current = $current.Parent", script)
self.assertIn("PropagationFlags]::InheritOnly", script)
self.assertNotIn("Register-ScheduledTask", script)
self.assertNotIn("Enable-ScheduledTask", script)
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--write-bundle-fixture":
output_path = Path(sys.argv[2])
repository = output_path.parent / "fixture-repository"
repository.mkdir()
subprocess.run(["git", "-C", str(repository), "init", "-b", "main"], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(["git", "-C", str(repository), "config", "user.name", "Akyldash Test"], check=True)
subprocess.run(["git", "-C", str(repository), "config", "user.email", "test@example.invalid"], check=True)
for name, relative in release_bundle.FILES.items():
path = repository / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(f"fixture:{name}\n".encode("ascii"))
subprocess.run(["git", "-C", str(repository), "add", "--", *release_bundle.FILES.values()], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(["git", "-C", str(repository), "commit", "-m", "bundle fixture"], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
commit = subprocess.run(["git", "-C", str(repository), "rev-parse", "HEAD"], check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout.strip()
payload = release_bundle.create_bundle(repository, commit)
output_path.write_bytes(payload)
print(json.dumps({"commit": commit, "sha256": hashlib.sha256(payload).hexdigest()}))
else:
unittest.main()
@@ -0,0 +1,4 @@
{
"schema_version": 1,
"enabled": false
}
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Apply only a previously prepared Akyldash release; disabled template requiring explicit owner activation.</Description>
<URI>\Akyldash\ReleaseAutoApply</URI>
</RegistrationInfo>
<Triggers>
<CalendarTrigger>
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
<Enabled>false</Enabled>
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
<Repetition>
<Interval>PT5M</Interval>
<Duration>P1D</Duration>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
</CalendarTrigger>
</Triggers>
<Principals>
<Principal id="Owner">
<UserId>REPLACE_WITH_OWNER_SID</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable>
<Enabled>false</Enabled>
<Hidden>false</Hidden>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="Owner">
<Exec>
<Command>C:\Python313\python.exe</Command>
<Arguments>-B -E -s -S &quot;C:\Program Files\Akyldash\windows_release_launcher.py&quot; --auto-apply-prepared</Arguments>
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
</Exec>
</Actions>
</Task>
@@ -6,7 +6,7 @@
</RegistrationInfo>
<Triggers>
<CalendarTrigger>
<StartBoundary>2026-11-02T09:00:00</StartBoundary>
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
<Enabled>false</Enabled>
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
<Repetition>
@@ -37,8 +37,8 @@
</Settings>
<Actions Context="Owner">
<Exec>
<Command>C:\Program Files\Akyldash\python.exe</Command>
<Arguments>&quot;C:\Program Files\Akyldash\receive_external.py&quot; --once</Arguments>
<Command>C:\Python313\python.exe</Command>
<Arguments>-B -E -s -S &quot;C:\Program Files\Akyldash\windows_release_launcher.py&quot; --once</Arguments>
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
</Exec>
</Actions>
+106
View File
@@ -0,0 +1,106 @@
"""Harden the fixed Windows Python process before loading the release receiver."""
from __future__ import annotations
import importlib
import ntpath
import os
import shutil
import sys
from pathlib import Path
PYTHON_EXE = Path(r"C:\Python313\python.exe")
APP_DIR = Path(__file__).resolve().parent
WINDOWS_TOOLS = {
"ssh.exe": Path(r"C:\Windows\System32\OpenSSH\ssh.exe"),
"git.exe": Path(r"C:\Program Files\Git\cmd\git.exe"),
"powershell.exe": Path(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"),
}
WINDOWS_PATH = os.pathsep.join((
r"C:\Windows\System32\OpenSSH",
r"C:\Program Files\Git\cmd",
r"C:\Windows\System32",
r"C:\Windows",
))
def configure_windows_tool_environment(*, environ=None, which=shutil.which,
windows_root: Path = Path(r"C:\Windows")) -> None:
"""Remove inherited command lookup and Git overrides before receiver imports."""
environment = os.environ if environ is None else environ
system_root = str(windows_root)
environment["SystemRoot"] = system_root
environment["PATH"] = WINDOWS_PATH
environment["PATHEXT"] = ".EXE"
environment["PSModulePath"] = ntpath.join(
str(windows_root), "System32", "WindowsPowerShell", "v1.0", "Modules")
for name in tuple(environment):
if name.upper().startswith("GIT_"):
del environment[name]
for command, expected in WINDOWS_TOOLS.items():
resolved = which(command)
if resolved is None or os.path.normcase(os.path.abspath(resolved)) != os.path.normcase(os.path.abspath(expected)):
raise RuntimeError(f"required Windows tool is missing from its protected path: {command}")
def _is_beneath(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
return True
except (OSError, ValueError):
return False
def configure_import_path(*, executable: Path, app_dir: Path, python_root: Path,
path_entries: list[str] | None = None,
expected_executable: Path = PYTHON_EXE,
protected_app_root: Path = Path(r"C:\Program Files")) -> None:
"""Keep only stdlib paths and the protected app directory; discard CWD/env paths."""
executable = Path(executable).resolve()
app_dir = Path(app_dir).resolve()
python_root = Path(python_root).resolve()
if os.path.normcase(str(executable)) != os.path.normcase(str(expected_executable.resolve())):
raise RuntimeError("release trigger must use the verified Python 3.13 installation")
if not app_dir.is_dir() or not _is_beneath(app_dir, protected_app_root):
raise RuntimeError("release receiver directory is outside protected Program Files")
if not python_root.is_dir() or not _is_beneath(executable, python_root):
raise RuntimeError("Python executable is outside its protected installation")
entries = sys.path if path_entries is None else path_entries
safe_stdlib = []
for value in entries:
if not value:
continue
candidate = Path(value)
if _is_beneath(candidate, python_root):
safe_stdlib.append(str(candidate))
if not safe_stdlib:
raise RuntimeError("Python standard library path is unavailable")
sys.path[:] = safe_stdlib + [str(app_dir)]
def _verify_module_origin(module, root: Path) -> None:
origin = getattr(module, "__file__", None)
if not isinstance(origin, str) or not _is_beneath(Path(origin), root):
raise RuntimeError(f"release module was loaded outside its protected directory: {module.__name__}")
def main() -> int:
flags = sys.flags
if not (flags.ignore_environment and flags.no_user_site and flags.no_site):
raise RuntimeError("launch Python with -E -s -S to disable environment and site imports")
configure_import_path(executable=Path(sys.executable), app_dir=APP_DIR,
python_root=PYTHON_EXE.parent)
if os.name == "nt":
configure_windows_tool_environment()
for name in ("json", "ssl", "subprocess", "pathlib"):
module = importlib.import_module(name)
_verify_module_origin(module, PYTHON_EXE.parent)
receiver = importlib.import_module("receive_external")
_verify_module_origin(receiver, APP_DIR)
for name in ("archive", "external_session"):
_verify_module_origin(importlib.import_module(name), APP_DIR)
return receiver.main()
if __name__ == "__main__":
raise SystemExit(main())
+28 -3
View File
@@ -8,7 +8,7 @@ const root = path.join(repo, 'backend/search/static/fonts');
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
fs.mkdirSync(artifacts, {recursive: true});
let fixtureAccount = passwordChangeAccount;
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[],lastReviewSubmission:null,rejectNextReviewSave:false};
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
(async()=>{
const browser = await chromium.launch({headless:true});
@@ -21,7 +21,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
if(p==='/search-reviews' && req.method()==='POST') { const submission=req.postDataJSON(); fixtureState.reviewSaves++; fixtureState.lastReviewSubmission=submission; fixtureState.lastReviewRatings=submission.results.map((result)=>({code:result.code,rating:result.rating})); if(fixtureState.rejectNextReviewSave){fixtureState.rejectNextReviewSave=false;return route.fulfill({status:400,json:{error:'Fixture review save error'}});} return route.fulfill({status:201,json:{id:42}}); }
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
@@ -30,7 +30,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
if(deleteMatch && req.method()==='DELETE') { fixtureState.userDeletes++; const username=decodeURIComponent(deleteMatch[1]); fixtureState.users=fixtureState.users.filter((candidate)=>candidate.username!==username); return route.fulfill({json:{}}); }
if(p==='/search') {
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
if(url.searchParams.has('page_size')&&!url.searchParams.has('sort')) { if(delayNextReviewSearch){delayNextReviewSearch=false;await new Promise(resolve=>setTimeout(resolve,300));} if(url.searchParams.get('q')==='empty-review-fixture') return route.fulfill({json:{results:[],has_next:false,review_token:'fixture-signed-empty-snapshot-token'}}); return route.fulfill({json:{results:reviewDocs,has_next:false,review_token:'fixture-review-token'}}); }
return route.fulfill(url.searchParams.get('q')==='service-error' ? {status:503,json:{error:'Fixture search error'}} : {json:{results:url.searchParams.get('q')==='not-found'?[]:docs,has_next:false,review_token:'fixture-review-token'}});
}
if(p==='/documents/doc-2/editions') return route.fulfill({json:{available_languages:[],editions:[]}});
@@ -85,6 +85,31 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const labGeometry=await page.evaluate(()=>{const rect=s=>document.querySelector(s).getBoundingClientRect(),action=rect('body > .review__actions'),workspace=rect('.review__workspace'),footer=rect('body > footer.review__header');return {headingLeft:rect('#route-heading').left,helpRight:rect('#tutorial-open').right,subtitleLeft:rect('#route-description').left,workspaceActionGap:action.top-workspace.bottom,actionFooterGap:footer.top-action.bottom,actionsHeight:action.height,footerHeight:footer.height};});
const tutorialScreens={}; for(const [name,viewport] of [['desktop',{width:1280,height:800}],['mobile',{width:390,height:844}]]){await page.setViewportSize(viewport); await page.locator('#tutorial-open').click(); tutorialScreens[name]=[]; for(const target of ['#query','#results','#document-panel','#save']){const selector=name==='mobile'&&target==='#document-panel'?'#document-heading':target;const geometry=await page.evaluate((selector)=>{const dialog=document.querySelector('#tutorial-dialog'),target=document.querySelector(selector),d=dialog.getBoundingClientRect(),t=target.getBoundingClientRect(),style=getComputedStyle(dialog),targetStyle=getComputedStyle(target);return {step:document.querySelector('#tutorial-progress').textContent,width:d.width,height:d.height,overlaps:d.left<t.right&&d.right>t.left&&d.top<t.bottom&&d.bottom>t.top,font:style.fontFamily,titleFont:getComputedStyle(document.querySelector('#tutorial-heading')).fontFamily,background:style.backgroundColor,targetDisplay:targetStyle.display,targetInViewport:t.bottom>0&&t.top<innerHeight,target:{left:t.left,top:t.top,right:t.right,bottom:t.bottom}}},selector);if(geometry.targetDisplay==='none'||geometry.target.right===geometry.target.left||geometry.target.bottom===geometry.target.top||!geometry.targetInViewport) throw new Error(`Tutorial target is not visible at ${name}: ${JSON.stringify(geometry)}`);if(geometry.overlaps) throw new Error(`Tutorial overlaps ${target} at ${name}: ${JSON.stringify(geometry)}`);if(geometry.width>320||!geometry.font.includes('Noto Sans')||!geometry.titleFont.includes('Noto Serif')||!geometry.background.includes('0.92')) throw new Error(`Tutorial style mismatch at ${name}: ${JSON.stringify(geometry)}`);tutorialScreens[name].push(geometry);if(target==='#query') await page.screenshot({path:path.join(artifacts,`review-tour-${name}.png`),fullPage:false});if(target!=='#save') await page.locator('#tutorial-next').press('Enter');}await page.locator('#tutorial-next').press('Enter');}
await page.setViewportSize({width:1280,height:800}); await page.locator('#query').fill('налог на прибыль'); delayNextReviewSearch=true; await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('#status').classList.contains('review__status--busy')); const labLoadingStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),busy:el.classList.contains('review__status--busy')})); await page.locator('.review__result').first().waitFor(); const successStatus=await page.locator('#status').evaluate(el=>({height:el.getBoundingClientRect().height,role:el.getAttribute('role'),live:el.getAttribute('aria-live'),success:el.classList.contains('review__status--success')})); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); const compactResult=await page.locator('.review__result--selected').evaluate(el=>({padding:getComputedStyle(el).padding,titleTarget:el.querySelector('.review__result-title').getBoundingClientRect().height,ratingTarget:el.querySelector('.review__rating label').getBoundingClientRect().height,metaMargin:getComputedStyle(el.querySelector('.review__meta')).marginBlockStart})); const reviewSaveEnabled=await page.locator('#save').isEnabled(); const commentDisclosure=page.locator('.review__comment-disclosure').first(); const commentStartsCollapsed=!(await commentDisclosure.evaluate(e=>e.open)); await commentDisclosure.locator('summary').click(); await commentDisclosure.locator('textarea').fill('Фиктивный комментарий для проверки черновика'); await page.locator('input[name="rating-0"][value="2"]').check(); await page.waitForFunction(()=>document.querySelector('#draft-status').textContent==='Черновик сохранён на этом устройстве.'); await page.locator('.review__result-title').first().click(); await page.locator('#document-body h2').first().waitFor(); await page.locator('#document-body').evaluate(body=>{for(let i=0;i<160;i++){const p=document.createElement('p');p.textContent='Эталонный длинный документ в безопасном browser fixture. '.repeat(5);body.append(p);}}); await page.setViewportSize({width:1280,height:720}); const reviewScroll=await page.locator('#document-panel').evaluate(panel=>({height:panel.getBoundingClientRect().height,client:panel.clientHeight,scroll:panel.scrollHeight,viewport:innerHeight,internalScroll:panel.scrollHeight>panel.clientHeight,saveAfter:Boolean(panel.compareDocumentPosition(document.querySelector('#save'))&Node.DOCUMENT_POSITION_FOLLOWING),footerAfterSave:Boolean(document.querySelector('#save').compareDocumentPosition(document.querySelector('body > footer.review__header'))&Node.DOCUMENT_POSITION_FOLLOWING)})); await page.locator('#save').scrollIntoViewIfNeeded(); const saveViewport=await page.locator('#save').evaluate(el=>{const r=el.getBoundingClientRect();const footer=document.querySelector('body > footer.review__header').getBoundingClientRect();return {top:r.top,bottom:r.bottom,visible:r.top>=0&&r.bottom<=innerHeight,footerVisible:footer.top>=0&&footer.bottom<=innerHeight}}); await page.screenshot({path:path.join(artifacts,'lab-save-footer-1280x720.png'),fullPage:false}); await page.locator('#save').click(); await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена')); const reviewFlow={reviewSaveEnabled,commentStartsCollapsed,commentExpanded:await commentDisclosure.evaluate(e=>e.open),draftStatus:await page.locator('#draft-status').textContent(),reviewScroll,saveViewport,reviewSaved:await page.locator('#status').textContent()};
const nonEmptyReviewSubmission=fixtureState.lastReviewSubmission;
if(!nonEmptyReviewSubmission||nonEmptyReviewSubmission.results.length!==2||nonEmptyReviewSubmission.review_token!=='fixture-review-token') throw new Error(`Non-empty review flow changed: ${JSON.stringify(nonEmptyReviewSubmission)}`);
await page.locator('#query').fill('empty-review-fixture');
await page.locator('#search-form button[type=submit]').click();
await page.locator('.review__empty').waitFor();
const emptyPrompt=await page.evaluate(()=>({buttonText:document.querySelector('#save').textContent,buttonEnabled:!document.querySelector('#save').disabled,ariaRequired:document.querySelector('#overall-comment').getAttribute('aria-required'),placeholder:document.querySelector('#overall-comment').placeholder,emptyState:document.querySelector('#results').textContent.includes('Результатов пока нет')}));
await page.screenshot({path:path.join(artifacts,'empty-review-required-comment.png'),fullPage:false});
const attemptsBeforeInvalid=fixtureState.reviewSaves;
await page.locator('#save').focus(); await page.keyboard.press('Enter');
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Добавьте комментарий к пустой выдаче'));
const invalidEmptyComment=await page.evaluate(()=>({focus:document.activeElement.id,message:document.querySelector('#status').textContent,role:document.querySelector('#status').getAttribute('role'),live:document.querySelector('#status').getAttribute('aria-live')}));
const invalidRequestCount=fixtureState.reviewSaves-attemptsBeforeInvalid;
await page.locator('#overall-comment').fill('Пустая выдача проверена в синтетическом browser fixture.');
fixtureState.rejectNextReviewSave=true;
await page.locator('#save').focus(); await page.keyboard.press('Enter');
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Не удалось сохранить'));
const failedEmptySubmission=fixtureState.lastReviewSubmission;
const failedSaveFeedback=await page.locator('#status').evaluate(el=>({message:el.textContent,role:el.getAttribute('role'),live:el.getAttribute('aria-live')}));
await page.locator('#save').focus(); await page.keyboard.press('Enter');
await page.waitForFunction(()=>document.querySelector('#status').textContent.includes('Оценка сохранена'));
const emptyReviewSubmission=fixtureState.lastReviewSubmission;
const emptyReviewFlow={emptyPrompt,invalidEmptyComment,invalidRequestCount,failedSaveFeedback,failedSubmission:failedEmptySubmission,savedSubmission:emptyReviewSubmission,feedback:await page.locator('#status').textContent()};
await page.screenshot({path:path.join(artifacts,'empty-review-saved.png'),fullPage:false});
console.log('emptyReviewFlow',JSON.stringify(emptyReviewFlow));
if(!emptyPrompt.buttonEnabled||emptyPrompt.buttonText!=='Сохранить пустую выдачу'||emptyPrompt.ariaRequired!=='true'||!emptyPrompt.placeholder.includes('обязательно')||!emptyPrompt.emptyState||invalidEmptyComment.focus!=='overall-comment'||invalidEmptyComment.role!=='alert'||invalidRequestCount!==0||failedSaveFeedback.role!=='alert'||failedSaveFeedback.live!=='assertive'||failedEmptySubmission.review_token!=='fixture-signed-empty-snapshot-token'||failedEmptySubmission.results.length!==0||!failedEmptySubmission.overall_comment||emptyReviewSubmission.review_token!=='fixture-signed-empty-snapshot-token'||emptyReviewSubmission.results.length!==0||emptyReviewSubmission.overall_comment!=='Пустая выдача проверена в синтетическом browser fixture.'||!emptyReviewFlow.feedback.includes('Оценка сохранена')) throw new Error(`Empty review browser contract failed: ${JSON.stringify(emptyReviewFlow)}`);
await page.locator('#query').fill('document-switch-fixture'); await page.locator('#search-form button[type=submit]').click(); await page.waitForFunction(()=>document.querySelector('.review__result-title span:last-child')?.textContent==='Fixture document A');
await page.locator('.review__comment-disclosure').first().locator('summary').click(); await page.locator('.review__comment').first().fill('Fixture-only note for result A.'); const fixtureRating=page.locator('input[name="rating-0"][value="2"]'); await fixtureRating.check(); const ratingBeforeDocumentSwitch=await fixtureRating.isChecked();
await page.locator('.review__result-title').nth(0).click(); await page.locator('#document-body h2').filter({hasText:'Fixture document A'}).waitFor();