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

# ADR-001: Нормализация legacy root-owned файлов при миграции на uid 1000 — детект + actionable-ошибка + процедура

Work Item: **ORCH-057** — follow-up ORCH-040 (legacy `root:root` файлы в `/repos` ломают создание worktree под uid 1000)
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0031-legacy-ownership-normalization.md`** (новый
leaf-компонент + startup-поведение, затрагивает весь инстанс → кросс-каттинг).

## Статус
Proposed

## Контекст

ORCH-040 перевёл оба контейнера на `user: "1000:1000"`, изменив **только** `docker-compose.yml`.
Смена `user:` не меняет владельца уже существующих файлов, созданных прежним root-контейнером.
Bind-mount `/home/slin/repos → /repos` содержал `root:root` каталоги (`_wt/`, старые worktree,
`.git/objects`, `data/runs` — 37 root-логов).

**Сверено по коду:**
- `src/git_worktree.py::ensure_worktree` (стр. 78 `os.makedirs(os.path.dirname(wt))`, стр. 81/85
  `git worktree add`) — точка реального падения. При `root:root` владельце `/repos/_wt/` uid 1000
  не может создать рядом новый каталог worktree → `fatal: could not create leading directories …
  Permission denied`. Сейчас этот stderr пробрасывается «сырым» в `RuntimeError` (стр. 90–93) без
  диагноза причины.
- Конвейер приходит сюда из `src/agents/launcher.py::_spawn` и `_materialize_deferred_branch`
  (ORCH-088, отложенный срез ветки на момент claim analyst-job). **Агент не стартует** — падает
  создание worktree (НЕ код задачи), т.е. это launch-time инфраструктурный сбой.
- Контейнер бежит под numeric uid 1000 **без root** (ORCH-040 P-3, ORCH-058 реальный user `slin`
  в образе). Под uid 1000 `chown` чужих (root) файлов **невозможен** без `CAP_CHOWN`. Значит код
  физически не может «починить» права сам — ему доступны только **детект + диагностика**, а
  фактический `chown` — операторская процедура.
- ADR-001 ORCH-040 упоминал «массовый chown старых root-файлов» лишь абстрактно («вне объёма кода»,
  «разовая операция Owner») и не дал конкретной процедуры → deployer её не выполнил → баг проявился
  в проде 06.06 на первом запуске ORCH-043. Прод сейчас рабочий (ручной workaround Стрима наложен),
  но проблема **воспроизводится** на чистой среде / новом репо / после любого исторического запуска
  под root.

Это **закрытие недоделанного AC ORCH-040**, а не новая фича. Существующие гейты/паттерны для опоры:
условный leaf-гейт `coverage_gate`/`serial_gate` (kill-switch + scope + `is_self_hosting_repo`),
best-effort startup-хуки в `main.lifespan` (lease-reclaim, log-rotation — never-fatal),
read-only снимки `GET /queue` (`serial_gate.snapshot()`), TTL-кэш `preflight._cache`.

## Решение

### Сводка
Три аддитивных, обратимых kill-switch'ем слоя, **без** изменения `STAGE_TRANSITIONS` / `QG_CHECKS` /
`check_*` / machine-verdict-ключей / схемы БД:

1. **Actionable-ошибка** в `ensure_worktree` — класс «нет прав на создание worktree» распознаётся и
   превращается в диагностируемый `RuntimeError` с причиной + лечащей командой (FR-1).
2. **Детект-леаф** `src/fs_normalize.py` — чистый, never-raise, TTL-кэшируемый обход корней, ищет
   файлы с `uid != target_uid` (FR-2); вызывается best-effort на старте сервиса с наблюдаемостью
   (FR-3).
3. **Операторская процедура** в `INFRA.md` + forward-breadcrumb из ADR-040 — точные команды разовой
   нормализации как обязательный шаг миграции uid (FR-5).

Фактический `chown` остаётся **операторской процедурой** (NFR-1: код под uid 1000 без root его делать
не может и не должен).

### D1 — `ensure_worktree`: классификация отказа доступа (FR-1, AC-1, AC-2)
Оборачиваем **обе** точки сбоя по правам — `os.makedirs(os.path.dirname(wt))` (стр. 78) и оба
`git worktree add` (стр. 81/85). Класс «нет прав» детектируется по маркерам в `stderr`/исключении:
`Permission denied`, `could not create leading directories`, `insufficient permission for adding an
object`, `PermissionError` (errno `EACCES`/`EPERM`). При совпадении — `RuntimeError`, текст которого:
(а) называет корневую причину («legacy root-owned файлы в `/repos/_wt` или `.git` после миграции uid
ORCH-040»); (б) указывает лечащую команду (`chown -R <target_uid>:<gid> /repos/_wt …`) и ссылку на
раздел INFRA.md; (в) **не** является сырым git stderr.

**Инвариант контракта (AC-2 FAIL-условие):** ошибки, **не** связанные с правами (реальный git-конфликт,
отсутствие `origin/main`, таймаут), сохраняют **прежний** текст/смысл — никакой подмены. Классификатор —
чистая функция `classify_worktree_error(stderr_or_exc) -> bool` (или хелпер в `fs_normalize`),
покрытая юнит-тестами на обе ветки. Помощь-сообщение строится только при `True`. Это **меняет лишь
формулировку** ошибки, не её факт (NFR-3): worktree как падал, так и падает — но теперь внятно.

### D2 — Детект-леаф `src/fs_normalize.py` (FR-2, AC-3)
Новый чистый модуль по образцу `serial_gate`/`post_deploy` (импортирует только `config`/`logging`/
`os`/`pwd`; не тянет `stage_engine`/`launcher`). API:

- `scan_ownership(roots: list[str] | None = None, target_uid: int | None = None) -> OwnershipScan` —
  обходит корни, возвращает `{mismatch: bool, target_uid: int, roots_checked: list, roots_mismatch:
  list, sample_path: str | None, count: int | None, checked_at: float}`.
- **`target_uid`** по умолчанию = `os.getuid()` (uid, под которым реально бежит процесс — ровно тот
  субъект, что «не может создать файл»); переопределяется `fs_target_uid` (дефолт 1000) для тестов/
  нестандартного рантайма.
- **Корни** по умолчанию: `/repos/_wt`, `<repo>/.git/objects`, `<repo>/.git/worktrees` (для репо из
  скоупа), `data/runs` (`os.path.dirname(settings.db_path)/runs`). Переопределяемы `fs_scan_roots`
  (CSV).
- **Дешевизна (риск стоимости обхода):** **ранний выход при первом mismatch** (для быстрого булева
  вердикта `os.lstat(...).st_uid != target_uid`). Полный `count` — опционален/семплирован (отдельный
  дешёвый режим, по умолчанию выключен), чтобы не обходить целиком большие `.git/objects`. Результат
  **кэшируется по TTL** `fs_scan_cache_ttl_s` (паттерн `preflight._cache`, `force=` обходит кэш).
- **never-raise (NFR-3):** любая ошибка обхода (исчезнувший путь, отказ stat) → деградирует в WARNING
  и консервативный вердикт `mismatch=False` (не блокирует и не паникует); идемпотентно (AC-3:
  повторный скан на чистой среде — `mismatch=False`, no-op).
- **`applies(repo: str) -> bool`** — `fs_normalize_enabled` (kill-switch) И scope (`fs_normalize_repos`
  CSV; пусто → `is_self_hosting_repo(repo)`, как `coverage_gate`); проверяется **ПЕРВЫМ**, дорогой
  обход — только при `applies==True` (NFR-2: enduro-trails не сканируется при пустом CSV).
- **`snapshot() -> dict`** — read-only для `GET /queue`.

### D3 — Точка интеграции: startup-наблюдаемость, БЕЗ блокировки claim (FR-3 — разрешение открытого выбора TRZ)
TRZ §2 оставил архитектору выбор «preflight vs queue_worker» для опц. гейта claim'а. **Решение:
claim НЕ блокируем.**

- **Startup (`main.lifespan`):** best-effort вызов `scan_ownership()` рядом с lease-reclaim/log-rotation
  (стр. 63–90), обёрнут `try/except` (never-fatal). При `mismatch` — структурный WARNING (число/корни/
  лечащая команда) + Telegram (если включён). Это даёт оператору **проактивный сигнал заранее**
  (AC-4), не дожидаясь падения задачи.
- **«Внятно и заранее» обеспечивает D1, а не claim-гейт.** `ensure_worktree` знает `repo` и падает
  до того, как агент потратит хоть один токен (агент не стартует). Это и есть требуемый ранний внятный
  исход.

**Почему НЕ блокирующий claim-гейт (отвергнуто):**
- `preflight.check()` **не знает repo** и гейтит claim **всех** репо → при mismatch в общем `/repos/_wt`
  заблокировал бы и enduro-trails (нарушение NFR-2 при включённом флаге). Сделать его scope-aware
  внутри preflight нельзя без знания репо в точке вызова.
- Гейт в `queue_worker`/`db.claim_next_job` (как `serial_gate`) технически scope-aware, но: (1)
  оставил бы задачу «молча висеть» в очереди вместо явного диагноза; (2) добавил бы дорогой FS-обход
  в offline hot-path claim'а; (3) дублировал бы исход, который D1 уже даёт внятно. Лишняя поверхность
  без выигрыша.

Итог: **детект = наблюдаемость (startup + опц. ручной POST), а внятный отказ = D1 в точке launch.**

### D4 — Опциональная авто-нормализация `normalize()` (FR-4) — не init-контейнер
`fs_normalize.normalize(roots, target_uid)` выполняет `os.chown`/`chown -R` по корням **только если
процесс имеет `CAP_CHOWN`/root**. Под uid 1000 без прав — **no-op + честный лог** «нужна операторская
процедура» (НЕ ошибка). Включается отдельным флагом `fs_normalize_auto` (дефолт `False` — детект-only).

**Init-контейнер/root-entrypoint отвергнут (см. Альтернативы):** он (а) реинтродуцирует root-контекст,
ровно который ORCH-040 убрал ради безопасности; (б) требует правки `docker-compose.yml`/entrypoint →
**self-deploy** с групповым риском (NFR-1) и обязательной staging-страховкой ради разовой задачи;
(в) discretionary по BRD §2 «Опционально». Носитель реальной нормализации — **документированная
операторская процедура** (D5), запускаемая под root **на хосте** один раз при миграции uid.

### D5 — Процедура в INFRA.md + forward-breadcrumb (FR-5, AC-7)
В `docs/operations/INFRA.md` (раздел «Рантайм-uid (ORCH-040)») добавляется подраздел **«Миграция uid:
обязательная нормализация legacy root-файлов»** с точными командами, покрывающими **все** корни
(`_wt`, оба `.git`, `data/runs`), помеченный как **обязательный** шаг миграции uid и пункт чеклиста
деплоя self. Существующий абстрактный буллет (стр. 50–51) заменяется ссылкой на новый подраздел.
В ADR-040 — необязательный forward-breadcrumb на ORCH-057 (история ORCH-040 не переписывается, §2 BRD).

### D6 — Конфиг-флаги (TRZ §7) и наблюдаемость
Аддитивно в `src/config.py` (существующие значения не трогаются):

| Флаг (env) | Дефолт | Смысл |
|------------|--------|-------|
| `fs_normalize_enabled` (`ORCH_FS_NORMALIZE_ENABLED`) | `True` | kill-switch; `False` → весь код инертен, поведение 1:1 как до ORCH-057 (D1 тоже гардится — при выкл. контракт ошибки прежний) |
| `fs_normalize_repos` (`ORCH_FS_NORMALIZE_REPOS`) | `""` | scope CSV; пусто → self-hosting only (`is_self_hosting_repo`) |
| `fs_target_uid` (`ORCH_FS_TARGET_UID`) | `1000` | целевой uid (фолбэк, если `os.getuid()` неприменим) |
| `fs_normalize_auto` (`ORCH_FS_NORMALIZE_AUTO`) | `False` | детект-only; `True` → попытка chown при наличии прав (D4) |
| `fs_scan_roots` (`ORCH_FS_SCAN_ROOTS`) | `""` | CSV-переопределение корней |
| `fs_scan_cache_ttl_s` (`ORCH_FS_SCAN_CACHE_TTL_S`) | `300` | TTL детект-кэша |

Наблюдаемость (AC-4): read-only блок `fs_ownership` в `GET /queue` (`snapshot()`:
`{enabled, target_uid, mismatch, roots_checked, roots_mismatch, checked_at}`); опц. ручной триггер
`POST /fs-normalize/check` (форс-пересчёт, по образцу `POST /serial-gate/unfreeze`). Telegram при
mismatch — с кликабельным номером задачи (если в контексте есть `work_item_id`), числом/корнями,
лечащей командой.

## Альтернативы
- **Init-контейнер / root-entrypoint, выполняющий `chown` на буте** — отвергнуто: реинтродуцирует
  root-контекст (анти-цель ORCH-040), требует правки `docker-compose.yml`/entrypoint = self-deploy +
  групповой риск + обязательная staging-страховка ради одноразовой операции; BRD помечает его
  «Опционально». Реальную нормализацию несёт документированная разовая операторская процедура.
- **Блокирующий claim-гейт в `preflight`** — отвергнуто: preflight не знает repo → блокирует claim
  ВСЕХ репо, регресс enduro-trails на общем `/repos` (нарушение NFR-2).
- **Блокирующий claim-гейт в `queue_worker`/`claim_next_job`** — отвергнуто: дорогой FS-обход в
  offline hot-path, «молчаливое зависание» вместо внятного диагноза, дублирует исход D1.
- **Авто-`chown` из app-кода по умолчанию** — отвергнуто: под uid 1000 невозможен; включение по
  умолчанию создавало бы ложное ожидание самолечения. Оставлен как opt-in `fs_normalize_auto` для
  сред, где процесс имеет CAP_CHOWN.
- **Жёсткий fail на старте при mismatch** — отвергнуто: нарушает never-raise (NFR-3) и мог бы
  застопорить старт сервиса всех проектов из-за грязного `/repos`. Детект — only WARNING/Telegram.

## Последствия
- **+** Класс «сырой git-fatal на launch после миграции uid» закрыт: оператор получает внятный
  диагноз + лечащую команду в точке падения (D1) и проактивный сигнал на старте (D3).
- **+** Воспроизводимая процедура в INFRA.md закрывает пробел ADR-040 (AC-7).
- **+** Нулевая регрессия enduro-trails (scope `applies()` first, пустой CSV → self-hosting only);
  `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — байт-в-байт прежние (AC-6).
- **+** Никакого root-контекста, рестарта прода, касания `main`/force-push/прод-образа (NFR-1, AC-5).
- **−** Фактический `chown` остаётся **ручным** операторским шагом — на средах, где его забыли, баг
  всё ещё проявится, но теперь **внятно** (с инструкцией), а не сырым git-fatal. Митигейшн:
  startup-WARNING+Telegram + обязательный пункт чеклиста миграции в INFRA.md.
- **−** Ещё один best-effort startup-хук + leaf-модуль (рост поверхности). Митигейшн: чистый
  never-raise leaf, TTL-кэш, ранний выход обхода, kill-switch.
- **−** `fs_normalize_auto=True` под root реинтродуцирует chown-контекст — поэтому дефолт `False` и
  он не для прод-self (прод бежит под uid 1000).
- **Откат:** `fs_normalize_enabled=False` → весь код инертен (D1 контракт ошибки прежний, детект не
  запускается); миграций/правки схемы нет → мгновенный обратимый kill-switch.

## Ссылки
- BRD: `docs/work-items/ORCH-057/01-brd.md`
- TRZ: `docs/work-items/ORCH-057/02-trz.md`
- Acceptance: `docs/work-items/ORCH-057/03-acceptance-criteria.md`
- Инфра: `docs/work-items/ORCH-057/07-infra-requirements.md`
- Риски: `docs/work-items/ORCH-057/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0031-legacy-ownership-normalization.md`
- Сверено по коду: `src/git_worktree.py` (`ensure_worktree` стр. 78/81/85/90), `src/preflight.py`
  (TTL-кэш), `src/main.py` (`lifespan` стр. 63–114), `src/serial_gate.py` / `src/coverage_gate.py`
  (паттерн условного leaf `applies`/scope/`is_self_hosting_repo`).
- Предшественник: `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md`,
  `docs/architecture/adr/adr-0005-container-runs-as-host-uid.md`.
