Зачем: владелец пометил N13 как «не воспроизведено», а в коде окно гонки (SELECT ... FOR UPDATE по отсутствующей строке, затем INSERT ... ON CONFLICT DO NOTHING со своим runId) видно. Нужен повторяемый замер на живом сервере и реальной PostgreSQL 17, а не рассуждение. Что: scripts/test/n13-race.mjs — 10/25/50 параллельных первых POST /payroll/calculate, каждая попытка на новой точке, проверка статусов, числа run_id, строк в staff_payroll_runs и «фантомных» run_id. Режим as-is показывает, что сейчас всё упирается в N2 (500). Режим n2-bypass ставит DEFAULT now() только в одноразовой БД скрипта (продукт и миграции не меняются), чтобы увидеть саму гонку. Итог: 0 некорректных из 30 попыток; причина (из кода) — случайная сериализация через upsert ensureDefaultPayRules. Это записано в NOTES-FOR-EXECUTORS вместе с рекомендацией явной блокировки и замечанием об утечке текста ошибок БД в 500.
17 KiB
Заметки для исполнителей (Codex / Antigravity)
Автор: Grok (стенд тестов и независимая приёмка). Я не меняю src/, apps/,
migrations/, deploy/; всё, что требует изменения продукта, описано здесь.
Как запускать:
# bootstrap-админ PostgreSQL 17 (только создаёт роли и БД; сервер и миграции под ним не работают)
export E2E_PG_ADMIN_URL=postgres://<admin>:<pass>@127.0.0.1:55433/postgres
node scripts/test/run-e2e.mjs # храповик known-defects (как в CI)
node scripts/test/run-e2e.mjs --no-known-defects # строгий режим: все дефекты красные
E2E_MIGRATIONS=roundtrip node scripts/test/run-e2e.mjs # + откат всех миграций перед запуском
./scripts/test/ci-local.sh # локальный эквивалент verify/format/e2e
Правило храповика. Исправили дефект → его сценарий позеленел →
run-e2e.mjs падает с «дефект исправлен, обновите known-defects.json» —
удалите ID из tests/e2e/known-defects.json в том же коммите. Новый красный
сценарий, которого нет в списке, — регрессия.
1. Предполагаемый контракт для решений владельца (OWN-01..11) — Codex, этап 4
API пока нет, поэтому сценарии tests/e2e/30-owner-decisions.e2e.ts написаны
против наиболее вероятного контракта. Если выбираете другой — поменяйте тесты и
этот раздел в том же PR, смысл проверок (кто что может и когда) менять нельзя.
Все пути относительно /api/v1/staff.
C1. Роли точки/бухгалтера/директора (standalone-аналог Core RBAC)
POST /access-grants(только главный менеджер,staff:employee:write) тело{ "role": "point_manager" | "accountant" | "director", "org_unit_ids": ["<ULID>"], "display_name": "..." }→201 { "id", "role", "org_unit_ids", "access_token" }. Токен показывается один раз, хранится как хеш (как ключи сотрудников), используется какAuthorization: Bearer.point_managerвидит/считает только своиorg_unit_ids; чужая точка →403(и для новогоPOST /payroll/batches, и для старогоPOST /payroll/calculate).- В platform-режиме те же роли приходят из Core (User ≠ Employee, AGENTS.md).
C2. Пакет зарплаты точки и согласование
| Метод и путь | Кто | Результат |
|---|---|---|
POST /payroll/batches {org_unit_id, from, to} |
accountant; point_manager своей точки | 201 {id, status:"draft", org_unit_id, lines:[{work_session_id, employee_id, org_unit_id, amount_minor}]} |
POST /payroll/batches/:id/submit |
только accountant | 200 {status:"submitted"}; point_manager → 403; повтор после подписи → 409 |
GET /notifications |
любой пользователь | 200 {notifications:[{type:"payroll.batch.submitted", subject_id:<batchId>}]}; каждому директору ровно одно уведомление на отправку |
POST /payroll/batches/:id/sign |
только director | 200 {status:"signed"}; accountant → 403; если смена пакета изменилась после отправки → 409, пакет получает status:"returned" |
POST /payroll/batches/:id/return {comment} |
director | 200 {status:"returned", return_comment} |
GET /payroll/batches/:id |
accountant, director | 200 {status, return_comment, lines:[...]} |
Инварианты, которые проверяют тесты:
- до подписи в
staff_payroll_ledgerдля сотрудников пакета нет ниaccrual, ниpayout(OWN-05); - подпись атомарно пишет
accrualиpayout; после подписиPATCH /work-sessions/:idдля смен пакета →409, пакет не отправляется повторно (OWN-06); - строка пакета принадлежит точке, где отработана смена (
staff_work_sessions.org_unit_id), а не основной точке сотрудника (OWN-09).
C3. Согласие на биометрию
PUT /employees/:id/biometric-consent{ "status": "granted" | "revoked", "document_ref": "..." }(главный менеджер) →200.GET /terminal/employees/:id/enrollment(терминальный токен) →200 { "employee_id", "registration_allowed": boolean, "reason": "consent_missing" | "consent_revoked" | null }. Без записи о согласии —false/consent_missing(OWN-10); после отзыва —false/consent_revoked(OWN-11).- Шаблоны отпечатков на сервер не передаются (docs/TERMINAL-API-REQUIREMENTS.md);
сервер хранит только факт согласия. Тот же флаг стоит отдать в
/sync/pull(employee payload).
Сейчас все OWN красные с 404 Route ... not found (C1–C3 не реализованы);
OWN-03..09 дополнительно упрутся в N2 (подтверждение смены падает 500).
2. Дефекты продукта, найденные сценариями (все красные сейчас)
Команда и фактический вывод — tests/e2e/evidence/e2e-run.txt; таблица — tests/e2e/REPORT.md.
| ID | Файл | Что исправить |
|---|---|---|
| N1 | src/portal.ts POST /employees |
42P08 inconsistent types deduced for parameter $2: явные $2::varchar(26), $5::text или отдельный запрос профессии |
| N2 | src/portal.ts PATCH /work-sessions, src/domain/payroll/service.ts calculate/approve |
staff_outbox.occurred_at NULL (23502): передавать occurred_at или DEFAULT now() новой миграцией |
| N4 | service.ts |
после N2 проверить: пересекающийся период не должен начислять смену повторно (409 или исключение уже начисленных смен) |
| N3 | push-подписки | allowlist push-сервисов / запрет приватных адресов, redirect:'manual'; сейчас сервер подключается к 127.0.0.1 |
| N9 | push-dispatcher.ts |
ON CONFLICT ... DO UPDATE SET employee_id → 409 для чужого endpoint |
| N10 | GET /professions, /org-units | требовать право справочника; терминал сейчас получает 200 |
| N11 | импорт | валидировать kind и схему items → 400 вместо 500 (23514) |
| N12 | PUT pay-assignment | additionalProperties:false, верхние границы сумм |
| F2 | config.ts/identity.ts |
standalone без токенов не должен выдавать права (процесс без токенов отдаёт 200 на /portal, /payroll, POST /professions) |
| F3 | POST/DELETE /session | в cookie — идентификатор сессии, а не сырой токен; logout отзывает сессию |
| F4 | вход | rate limit (тест ждёт 429 не позже 30 неверных попыток; идёт на отдельном процессе, чтобы лимит не мешал другим сценариям) |
| F7 | POST /attendance/events | использовать Idempotency-Key / staff_idempotency_keys |
| F10 | assertSameOrigin |
мутации по cookie без Origin отклонять |
| SYNC-A/B | src/domain/sync/service.ts |
устройство не должно выставлять confirmed/approved (сейчас applied) |
| SYNC-C0 | pull() bootstrap |
SELECT ... status FROM staff_org_units — такого столбца нет → 500 на любой начальной загрузке |
| SYNC-C | pull() bootstrap |
при has_more=true отдаётся next_cursor=latest, остаток после 100 сущностей теряется; нужен курсор снимка |
| SYNC-D | портал/импорт | изменения вне sync не пишутся в staff_sync_change_log → терминал не узнаёт о новых/изменённых сотрудниках |
| SYNC-E | executeCommand |
проверять status='active' сотрудника |
| SYNC-F | executeCommand |
сохранять org_unit_id в staff_attendance_events; payload не может переопределить точку устройства |
| SYNC-G | push() |
ошибки БД одной команды → rejected для неё, а не 500 и откат всего пакета |
| SYNC-H | /sync/devices |
assertCookieMutationOrigin как в портале |
| SYNC-I | requireManagerContext |
без токенов не выдавать права (как F2) |
| SYNC-J | executeCommand |
окно времени отметки как в POST /attendance/events (−180 дней … +5 мин) |
| SYNC-K | acknowledge() |
отклонять курсор больше MAX(cursor_seq) |
3. deploy/ (PILOT-1, PILOT-2) — Codex
- PILOT-1: все подстановки в
deploy/pilot/compose.yaml→${VAR:?VAR is required}(сейчас 14 без:?: пустойSTANDALONE_MANAGER_TOKENмолча превращает pilot в F2). - PILOT-2:
NODE_ENV: developmentвapp. Конфликт:config.tsзапрещаетSTAFF_RUNTIME_MODE=standaloneприNODE_ENV=production. Нужно решение: либо разрешить standalone в production при обязательных токенах и HTTPS, либо отдельный режим для pilot. Тест требует только «не development». - N6:
scripts/create-app-role.mjsне работает мигратором без SUPERUSER («Only roles with SUPERUSER may change the SUPERUSER attribute»). CI и e2e обходятсяscripts/test/provision-pg.mjs(роли создаёт bootstrap-админ, какdocker/postgres/init-app-role.sh). В pilot мигратор =POSTGRES_USER= суперпользователь; рекомендую схему как в provision-pg.mjs.
4. CI
- Задаче
docker-buildнужен раннер с меткойdocker-host(Docker daemon). Без него задача ждёт в очереди; остальные задачи не блокируются. formatкрасная, пока не исправлен F14 (25 файлов, в основномsrc/,docs/,tests/unit/).- Реальный Forgejo-раннер мной не запускался (нет API-токена) — не проверено.
5. N13 (гонка первого расчёта) и утечка текста ошибок БД
scripts/test/n13-race.mjs, выводtests/e2e/evidence/n13-race.txt: 10/25/50 параллельных первыхPOST /payroll/calculate× 5 попыток. Как есть — все 500 (N2), гонку не видно. В одноразовой БД с обходом N2 (DEFAULT now()только там) — 0 из 30 некорректных попыток (без смен и с подтверждённой сменой): одинrun_id, один расчёт в БД.- Почему не воспроизводится (вывод из кода, не проверено отдельным экспериментом):
calculatePayrollсначала вызываетensureDefaultPayRules, а тамINSERT ... ON CONFLICT DO UPDATEпо одним и тем же строкамstaff_pay_rulesтенанта. Первая транзакция держит блокировки этих строк до коммита, остальные ждут — расчёты одного тенанта выполняются по очереди случайно. Если upsert правил вынесут из расчёта или сделаютDO NOTHING, окноSELECT ... FOR UPDATE(строки нет) →INSERT ... ON CONFLICT DO NOTHINGсо своимrunId→ строки/UPDATE по несуществующемуrunIdвернётся. Рекомендация: явныйpg_advisory_xact_lockпо (tenant, org_unit, period_start) илиINSERT ... ON CONFLICT DO UPDATE ... RETURNING id. Скрипт стоит перезапустить после такого изменения. - Ответы 500 содержат сырой текст ошибки PostgreSQL (например
null value in column "occurred_at" of relation "staff_outbox" ...) — клиенту видна схема БД. Для 5xx отдавать нейтральное сообщение, подробности — только в лог.