---
work_item: ORCH-057
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-10
model_used: claude-opus-4-8
---

# adr-0031: Нормализация legacy root-owned файлов при миграции uid — детект-leaf + actionable worktree-ошибка

- **Статус:** proposed
- **Дата:** 2026-06-10
- **Задача:** ORCH-057 (follow-up ORCH-040)
- **Детальный ADR:** `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md`

## Контекст
ORCH-040 перевёл контейнеры на `user: "1000:1000"`, изменив только `docker-compose.yml`. Владельца
уже существующих `root:root` файлов в bind-mount `/repos` это не меняет. Под uid 1000
`src/git_worktree.py::ensure_worktree` (`os.makedirs` стр. 78 / `git worktree add` стр. 81/85) не может
создать worktree рядом с root-owned `/repos/_wt/` → `fatal: could not create leading directories …
Permission denied`, который сейчас пробрасывается сырым. Конвейер приходит сюда из
`launcher._spawn`/`_materialize_deferred_branch` (ORCH-088) — **агент не стартует** (launch-time
инфра-сбой, не код задачи). Инцидент 06.06 на проде (первый запуск ORCH-043); workaround Стрима
(`chown -R 1000:1000`) наложен вручную. ADR-040 описал нормализацию абстрактно («вне объёма кода») и
не дал процедуры → баг воспроизводим на чистой среде / новом репо / после исторического запуска под
root. Контейнер бежит **без root** → код физически не может `chown` чужие файлы; ему доступны лишь
детект + диагностика.

## Решение
Три аддитивных, обратимых kill-switch'ем слоя — паттерн условного leaf-гейта (`coverage_gate`/
`serial_gate`) + best-effort startup-хук (`main.lifespan`, как lease-reclaim). `STAGE_TRANSITIONS` /
`QG_CHECKS` / `check_*` / machine-verdict-ключи (`verdict:`/`result:`/`deploy_status:`/
`staging_status:`/`security_status:`/`coverage_status:`) / схема БД — **байт-в-байт прежние**.

- **Actionable worktree-ошибка (D1):** `ensure_worktree` классифицирует класс «нет прав» (маркеры
  `Permission denied`/`could not create leading directories`/`insufficient permission`/`EACCES`/
  `EPERM`) и поднимает `RuntimeError` с причиной (legacy root-файлы после миграции uid) + лечащей
  командой + ссылкой на INFRA.md. Не-прав-ошибки сохраняют прежний текст/смысл (никакой подмены).
  Меняется лишь **формулировка**, не факт сбоя.
- **Детект-leaf `src/fs_normalize.py` (D2):** чистый, never-raise, TTL-кэш (паттерн `preflight`).
  `scan_ownership(roots, target_uid)` обходит `/repos/_wt`, `<repo>/.git/objects`,
  `<repo>/.git/worktrees`, `data/runs`; ранний выход при первом `st_uid != target_uid`
  (`target_uid=os.getuid()` по умолчанию). `applies(repo)` (kill-switch + scope; пусто →
  `is_self_hosting_repo`) проверяется ПЕРВЫМ → дорогой обход только при applies. Идемпотентно;
  ошибка обхода → WARNING + консервативный `mismatch=False`.
- **Интеграция = наблюдаемость, без блокировки claim (D3):** best-effort `scan_ownership()` на старте
  `main.lifespan` → WARNING + Telegram при mismatch. Claim НЕ гейтится: внятный ранний отказ даёт D1
  в точке launch (знает repo, агент ещё не тратил токены). Блокирующий preflight-гейт отвергнут —
  preflight не знает repo, заблокировал бы и enduro-trails на общем `/repos`.
- **Опц. `normalize()` (D4):** chown только при `CAP_CHOWN`/root (под uid 1000 — no-op + лог),
  флаг `fs_normalize_auto` (дефолт `False`). Init-контейнер/root-entrypoint отвергнут: реинтродукция
  root-контекста (анти-цель ORCH-040) + правка compose = self-deploy/групповой риск. Реальную
  нормализацию несёт операторская процедура.
- **Процедура (D5):** `INFRA.md` получает раздел «Миграция uid: обязательная нормализация legacy
  root-файлов» (точные команды по всем корням) как обязательный шаг миграции; forward-breadcrumb из
  ADR-040.
- **Флаги:** `fs_normalize_enabled` (kill-switch, дефолт `True`), `fs_normalize_repos` (CSV, пусто →
  self-hosting only), `fs_target_uid` (1000), `fs_normalize_auto` (`False`), `fs_scan_roots`,
  `fs_scan_cache_ttl_s` (300). Наблюдаемость — блок `fs_ownership` в `GET /queue`; опц. `POST
  /fs-normalize/check`.

## Альтернативы
- **Init-контейнер/root-entrypoint** — реинтродукция root (анти-цель ORCH-040), self-deploy compose,
  групповой риск ради разовой операции. Отвергнуто; носитель нормализации — операторская процедура.
- **Блокирующий claim-гейт (preflight)** — preflight не знает repo → регресс enduro на общем `/repos`.
  Отвергнуто.
- **Блокирующий claim-гейт (queue_worker/claim)** — дорогой FS-обход в hot-path + «молчаливое
  зависание» вместо диагноза D1. Отвергнуто.
- **Авто-chown из app по умолчанию** — под uid 1000 невозможен; ложное ожидание самолечения.
  Отвергнуто (оставлен opt-in `fs_normalize_auto`).
- **Hard-fail старта при mismatch** — нарушает never-raise, стопорит сервис всех проектов. Отвергнуто.

## Последствия
- Класс «сырой git-fatal на launch после миграции uid» закрыт внятным диагнозом (D1) + проактивным
  startup-сигналом (D3); пробел процедуры ADR-040 закрыт (INFRA.md).
- Нулевая регрессия enduro-trails (scope first); инварианты конвейера/схема БД — байт-в-байт.
- Никакого root-контекста/рестарта прода/касания `main`/force-push/прод-образа (NFR-1).
- Плата: фактический `chown` остаётся ручным операторским шагом (но теперь внятным, с инструкцией);
  +1 best-effort startup-хук и leaf-модуль; `fs_normalize_auto=True` под root реинтродуцирует
  chown-контекст (дефолт `False`, не для прод-self).
- Аддитивно/обратимо: **не** `arch:major-change` (нет новой стадии/QG/таблицы/смены топологии) — leaf
  + startup-хук + docs.
- **Откат:** `fs_normalize_enabled=False` → полный no-op (мгновенный обратимый kill-switch).

## Связи
adr-0005 (контейнер под host-uid — порождающее решение ORCH-040, чей пробел закрываем),
adr-0029/adr-0012 (coverage/security-гейт — паттерн условного leaf `applies`/scope/never-raise/
fail-open), adr-0017 (serial-gate — leaf never-raise + отложенный срез ветки `_materialize_deferred_
branch`, чья точка падает в `ensure_worktree`), adr-0011 (job-reaper — образец best-effort
startup-хука в `lifespan`), adr-0024 (disk-watchdog — образец «только читать/уведомлять, не трогать
хост/прод»).
