Compare commits

..

7 Commits

Author SHA1 Message Date
Дмитрий 6ce2f0058d fix(router-gate): session-lock skips readonly Bash (scope calibration)
The parallel-session-lock fired on every PreToolUse tool, blocking even
readonly Bash (git status/log/diff, cat, grep, ls) from a peer session.
The lock's purpose is to serialize concurrent FILE MUTATION on the same
worktree; readonly commands mutate nothing, so they are outside that scope.

isReadonlyBashEvent() reuses the router-gate Bash classifier (an allow-verdict
whose reason is readonly/reading), mirroring the LLM-judge readonly
calibration. main() short-circuits readonly Bash to allow without
acquiring/blocking. Mutating tools, git commit/push, dangerous Bash, and
every non-Bash tool still acquire/check the lock — same-worktree mutation
serialization is unchanged (scope fix, NOT a discipline drop).

TDD: +6 unit tests. Full tools-vitest 2038 passed / 2 skipped.
2026-06-01 07:46:26 +03:00
Дмитрий d35fefddd9 ci(a11y): bump Pa11y workflow Node 20 -> 22 (cspell@10 engine requirement)
The a11y (Pa11y live) PR check failed at "Install root JS deps": root `npm ci`
hits EBADENGINE because @cspell/cspell-*@10.0.0 require Node >=22.18.0 while the
workflow pinned Node 20. Pre-existing mismatch (cspell ^10 predates this branch
and fails identically on main), unrelated to the discipline-guard hook changes.
Node 22 satisfies both the repo engines (>=20) and cspell (>=22.18).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:00:05 +03:00
Дмитрий e56ddd6a1b fix(router-gate): coverage line honors cross-turn active skill (verify + remind)
Backlog item G. The `coverage:` line under-reported a skill chosen in a PRIOR turn:
enforce-coverage-verify credited channel=skill only if the Skill tool ran in the
CURRENT turn, so an honest `skill:X` continuation line was BLOCKED -> the controller
learned to under-report as direct/chain. Two-sided systemic fix, no weakening:

- enforce-coverage-verify: decide() also accepts skill:X when X was invoked anywhere
  earlier in THIS session (new priorSkillNames param; main() collects them via
  sessionToolUses). Still unforgeable -- a real Skill tool_use must exist in the
  transcript. The only residual is possibly-stale attribution, far better than the
  forced dishonest direct-reporting it replaces.
- enforce-prompt-injection: the §17 reminder now lists active skills carried over
  from earlier turns (read from the transcript) and tells the controller to report
  `coverage: skill:<name>` when work continues under one -- the proactive half, so
  the correct line is not merely allowed but prompted.

TDD: RED -> GREEN per behavior. tools-vitest 2032 passed / 2 skipped.
Plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md (item G).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:37:44 +03:00
Дмитрий 53407a77cd feat(router-gate): tdd-gate credits delegated (subagent) TDD + transcript write-deny
Closes the TDD-gate cross-actor gap: when a subagent (spawned by a Task in the
controller's current turn) writes the failing test and confirms RED, the
controller's subsequent production edit was falsely blocked because the gate only
scanned the controller's own turn. Net strengthening, no discipline weakened.

- Part 1 (enforce-runtime-write-deny): block the Write tool from any
  ~/.claude/projects/**/*.jsonl (session/subagent transcripts). Memory *.md there
  stays writable (never matches .jsonl$). Resolving normalizer defeats ./.. evasion.
  This makes the agent-<id>.jsonl that Part 2 trusts unforgeable (it was the last
  ungated write channel; Bash/PowerShell/Read gates already covered it).
- Part 2 (enforce-tdd-gate): decide() also credits a subagent's matching test edit
  + RED via a new subagentEntriesList. turnTaskAgentIds() reads the hex agentId from
  the harness-written Task tool_result (the controller cannot forge its own
  tool_result; hex-only match blocks "agentId: ../../x" path traversal).
  subagentTranscriptPaths() derives <dir>/<controller-session>/subagents/agent-<id>.jsonl.
  main() reads them best-effort (missing/unreadable -> no extra credit = stricter).

No new weakening: a delegated subagent doing real TDD is legitimate; the only
forgery vector (overwriting the agent jsonl) is closed by Part 1. Existing
controller-turn behaviour is preserved (empty subagent list == old logic).

OWNER (settings.json, Claude can't edit it): enforce-tdd-gate is already a
registered PreToolUse hook -> Part 2 goes live on merge. enforce-runtime-write-deny
must be registered on PreToolUse(Edit|Write|MultiEdit|NotebookEdit) for Part 1 to be live.

TDD: RED -> GREEN per behavior. tools-vitest 2027 passed / 2 skipped.
Backlog item C (=Z); plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:18:44 +03:00
Дмитрий 6577c04a1f fix(router-gate): session-lock hygiene — clearer block message + stale-lock prune
Closes the remaining parallel-session-lock remarks on top of the keying fix
(7a469dc9), with NO weakening of same-worktree serialization:

- D: the block message now identifies the holder by its STABLE session_id and
  marks the recorded pid as transient ("may change between attempts"). Chasing
  the pid is what led to closing the wrong session. Decision logic is unchanged
  (text only) — existing /pid N/ triage assertion still holds.
- B: pruneStaleLocks() best-effort deletes leaked lock files that are ALREADY
  stale by the shared isStale() definition (now exported from the pure module —
  single source of truth). Active within-TTL locks are never touched, so the
  serialization guarantee is not weakened. Wired into the PreToolUse branch of
  main(), wrapped so hygiene can never break the gate (fail-open).
- C (no code): release-on-SessionEnd needs only a settings.json registration
  (owner action) — the existing !tool_name branch already releases. Documented
  in the plan. Until then, leaked locks self-heal via B + the 5-min TTL takeover.

TDD: RED -> GREEN per behavior. tools-vitest 2014 passed / 2 skipped.
Backlog items B/C/D; plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:43:03 +03:00
Дмитрий 7a469dc913 fix(router-gate): key session-lock by session work-tree root, not hook cwd
enforce-parallel-session-lock keyed the lock on the hook's process.cwd(),
which collapses to the main repo dir after a session resume — so sessions in
DIFFERENT git worktrees shared one lock and false-blocked each other (observed:
a brainrepo-worktree session blocked launching agents by a discipline-guard
session). New resolveWorkspacePath() keys on the session's stable cwd
(event.cwd) resolved to the git work-tree root (git -C <cwd> rev-parse
--show-toplevel), with fallback to process.cwd() so behaviour never regresses
when event.cwd is absent. Same-worktree concurrency stays serialized
(unchanged) — discipline not weakened; only cross-worktree false-blocks fixed.

TDD: RED (5 resolveWorkspacePath cases) -> GREEN -> tools-vitest 2003 passed /
2 skipped. Backlog item F; plan
docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:02:32 +03:00
Дмитрий be4e1a6123 feat(router-gate): whitelist npm ci in SAFE_EXACT (worktree dep restore)
`npm ci` does a clean install strictly from the committed lockfile
(deterministic, no version drift) — needed to restore junction node_modules
in a fresh worktree. Distinct from `npm install`/`npm i`, which stay
hard-blacklisted because they can pull new/updated versions; the blacklist
runs before the whitelist, so they remain blocked. Word boundary after `ci`
prevents `npm cider`-style prefix matches; chain semantics still block
`npm ci && <mutating>`.

TDD: RED (3 allow-cases failed default-deny) -> GREEN (/^npm\s+ci\b/) ->
tools-vitest 1998 passed / 2 skipped (2000). Backlog item A; plan
docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 14:46:58 +03:00
81 changed files with 996 additions and 457865 deletions
Binary file not shown.
+8 -89
View File
@@ -9,26 +9,6 @@ on:
jobs:
a11y:
runs-on: ubuntu-latest
timeout-minutes: 20
# Полноценный PostgreSQL для CI: схема Лидерры — чисто PG (RLS, партиции,
# роли БД, raw schema.sql через load_initial_schema), на SQLite не грузится.
# Без живой БД 14 авторизованных Pa11y-маршрутов не могут залогиниться под
# admin@demo.local → таймаут на "wait for path /dashboard" → красный CI.
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: liderra
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Checkout
@@ -41,10 +21,10 @@ jobs:
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install root JS deps
@@ -55,27 +35,8 @@ jobs:
run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
- name: Install app JS deps
# --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7,
# установлено vite 8 (memory feedback_environment.md #74) — как в deploy.yml.
working-directory: app
run: npm ci --no-audit --no-fund --legacy-peer-deps
- name: Create PostgreSQL roles
# Базовая schema.sql грузится без ролей (GRANT'ы обёрнуты в DO $$ EXISTS-check),
# но поздние миграции (snapshot, lead-region) делают необёрнутый
# GRANT ... TO crm_app_user/crm_supplier_worker → роли должны существовать.
# SET ROLE crm_migrator в этих миграциях с guard'ом has_schema_privilege →
# под postgres-суперюзером корректно делает RESET ROLE (грантов на public нет).
env:
PGPASSWORD: postgres
run: |
psql -h 127.0.0.1 -U postgres -d liderra -v ON_ERROR_STOP=1 \
-v crm_app_password=ci_pa11y \
-v crm_admin_password=ci_pa11y \
-v crm_migrator_password=ci_pa11y \
-v crm_audit_writer_password=ci_pa11y \
-v crm_supplier_worker_password=ci_pa11y \
-f db/00_create_roles.sql
run: npm ci --no-audit --no-fund
- name: Bootstrap .env + key
working-directory: app
@@ -83,56 +44,19 @@ jobs:
cp .env.example .env
php artisan key:generate --force
- name: Configure .env for CI PostgreSQL + Sanctum SPA
# phpdotenv: первое вхождение ключа выигрывает → не дописываем дубли,
# а удаляем строку и добавляем заново (детерминированный override).
# APP_ENV=local нужен, чтобы DatabaseSeeder вызвал DemoSeeder (admin@demo.local)
# и чтобы session-cookie не был secure-only (вход по http в CI).
# SANCTUM_STATEFUL_DOMAINS обязан включать localhost:8000 — иначе Sanctum
# считает запрос с Pa11y-хоста (localhost:8000) stateless → сессия не залипает.
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
working-directory: app
run: |
setenv() { sed -i "/^$1=/d" .env; echo "$1=$2" >> .env; }
setenv APP_ENV local
setenv APP_DEBUG true
setenv APP_URL http://localhost:8000
setenv DB_CONNECTION pgsql
setenv DB_HOST 127.0.0.1
setenv DB_PORT 5432
setenv DB_DATABASE liderra
setenv DB_USERNAME postgres
setenv DB_PASSWORD postgres
setenv DB_SSLMODE disable
setenv SESSION_DRIVER file
setenv CACHE_STORE file
setenv QUEUE_CONNECTION sync
setenv MAIL_MAILER log
setenv SANCTUM_STATEFUL_DOMAINS localhost:8000,127.0.0.1:8000,localhost,127.0.0.1
- name: Run migrations (postgres superuser → guarded SET ROLE works)
working-directory: app
run: php artisan migrate --force
- name: Create current-month partitions
# schema.sql создаёт baseline-партиции; cron-команда докидывает текущий +2
# месяца (идемпотентно) — нужно для demo-сделок DemoSeeder'а за «сегодня».
working-directory: app
run: php artisan partitions:create-months --ahead=2
- name: Seed demo data (PricingTier + DemoSeeder admin@demo.local)
working-directory: app
run: php artisan db:seed --force
touch database/database.sqlite
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
- name: Build frontend assets
working-directory: app
run: npm run build
- name: Start Laravel dev-server
# PHP_CLI_SERVER_WORKERS>1: встроенный сервер обслуживает SPA + sub-resources
# параллельно, чтобы Pa11y-навигации не упирались в однопоточность.
working-directory: app
env:
PHP_CLI_SERVER_WORKERS: 4
run: nohup php artisan serve --host=127.0.0.1 --port=8000 > /tmp/laravel-serve.log 2>&1 &
- name: Wait for dev-server ready
@@ -148,14 +72,9 @@ jobs:
tail -50 /tmp/laravel-serve.log
exit 1
- name: Run Pa11y (live Vue, 7 public + 14 authenticated routes)
- name: Run Pa11y (live Vue)
run: npm run a11y
- name: Laravel log tail on failure
if: failure()
working-directory: app
run: tail -120 storage/logs/laravel.log || echo "no laravel.log"
- name: Upload Pa11y screenshots
if: always()
uses: actions/upload-artifact@v4
+2 -2
View File
@@ -45,10 +45,10 @@ jobs:
echo "Requested: '$CMD_TRIM'"
# Group 1 — read-only / dry-run / inspection: всегда разрешены
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run|deals:backfill-region-city --dry-run)( *)$'
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run)( *)$'
# Group 2 — mutating: требуют confirm_apply=true
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?|deals:backfill-region-city)( *)$'
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?)( *)$'
if [[ "$CMD_TRIM" =~ $READ_ONLY_RE ]]; then
echo "::notice::Command in read-only whitelist — proceeding."
-401
View File
@@ -1,401 +0,0 @@
name: Lead region — prod ops
# Самодостаточный launch-инструмент фичи lead-region-resolution.
# Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml.
#
# op:
# pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres
# superuser (crm_app_user не член crm_migrator → обычный migrate
# падает) + пометить применённой, чтобы deploy её пропустил.
# set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED
# (input flag) в боевой .env, перекэшировать config, рестарт очереди.
# fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz.
# import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт
# через pgsql_supplier = crm_supplier_worker, член crm_migrator).
# smoke — phone-region:smoke --phone=<input phone> под www-data (нужны ключи).
#
# Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET.
on:
workflow_dispatch:
inputs:
op:
description: 'Операция'
required: true
type: choice
options:
- pre-migrate
- set-env
- fetch-rossvyaz
- fetch-via-runner
- deliver-from-repo
- import
- smoke
flag:
description: 'set-env: LEAD_REGION_RESOLVER_ENABLED'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
url:
description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи'
required: false
type: string
dir:
description: 'import: каталог с CSV на проде'
required: false
default: '/var/www/liderra/rossvyaz'
type: string
dry_run:
description: 'import: только staging без swap'
required: false
default: true
type: boolean
force:
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
required: false
default: false
type: boolean
phone:
description: 'smoke: телефон'
required: false
default: '79161234567'
type: string
jobs:
op:
name: ${{ github.event.inputs.op }}
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: liderra-prod-deploy
cancel-in-progress: false
env:
LIDERRA_HOST: 111.88.246.137
LIDERRA_USER: ubuntu
APP_DIR: /var/www/liderra/app
OP: ${{ github.event.inputs.op }}
FLAG: ${{ github.event.inputs.flag }}
URL: ${{ github.event.inputs.url }}
DIR: ${{ github.event.inputs.dir }}
DRY: ${{ github.event.inputs.dry_run }}
FORCE: ${{ github.event.inputs.force }}
PHONE: ${{ github.event.inputs.phone }}
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
chmod 600 ~/.ssh/liderra_deploy
ssh-keyscan -H "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Checkout repo (for deliver-from-repo)
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
uses: actions/checkout@v4
- name: op=pre-migrate (superuser DDL + mark applied)
if: ${{ github.event.inputs.op == 'pre-migrate' }}
run: |
SQL_B64=$(cat <<'SQLEOF' | base64 -w0
BEGIN;
-- 1. phone_ranges_imports (FK target — создаём первым)
CREATE TABLE phone_ranges_imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url TEXT NOT NULL,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_updated INTEGER NOT NULL DEFAULT 0,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
error TEXT,
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE phone_ranges_imports IS
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
-- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные)
CREATE TABLE phone_ranges (
id BIGSERIAL PRIMARY KEY,
def_code SMALLINT NOT NULL,
from_num BIGINT NOT NULL,
to_num BIGINT NOT NULL,
operator TEXT NOT NULL,
region TEXT NOT NULL,
region_normalized TEXT,
subject_code SMALLINT,
imported_at TIMESTAMPTZ NOT NULL,
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
COMMENT ON TABLE phone_ranges IS
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.';
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
CREATE TABLE lead_region_resolution_log (
id BIGSERIAL,
supplier_lead_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
phone_masked TEXT NOT NULL,
subject_code_resolved SMALLINT,
subject_code_from_tag SMALLINT,
region_source TEXT NOT NULL
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
dadata_qc SMALLINT,
dadata_provider TEXT,
dadata_type TEXT,
dadata_response_masked JSONB,
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
actual_subject_code SMALLINT
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
substituted_subject_code SMALLINT
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
routing_step SMALLINT
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
phone_operator TEXT,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
COMMENT ON TABLE lead_region_resolution_log IS
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.';
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
CREATE TABLE lead_region_resolution_log_y2026_m05
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE lead_region_resolution_log_y2026_m06
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- 4. supplier_leads: +4 колонки
ALTER TABLE supplier_leads
ADD COLUMN resolved_subject_code SMALLINT
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
ADD COLUMN region_source TEXT
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
ADD COLUMN dadata_qc SMALLINT,
ADD COLUMN phone_operator TEXT;
-- 5. deals: +2 колонки
ALTER TABLE deals
ADD COLUMN phone_operator TEXT,
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
-- ownership как у миграции (она шла бы под crm_migrator)
ALTER TABLE phone_ranges_imports OWNER TO crm_migrator;
ALTER TABLE phone_ranges OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator;
ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator;
-- retention (system_settings, 12 мес)
INSERT INTO system_settings (key, value, type, description, updated_at)
SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int',
'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW()
WHERE NOT EXISTS (
SELECT 1 FROM system_settings
WHERE key = 'partition_retention_months_lead_region_resolution_log');
COMMIT;
SQLEOF
)
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log'
ALREADY=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM migrations WHERE migration='${MIG_NAME}' LIMIT 1")
if [ "${ALREADY}" = "1" ]; then
echo "Migration ${MIG_NAME} уже применена — пропускаю."
exit 0
fi
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1")
if [ "${TABLE_EXISTS}" != "1" ]; then
echo "Применяю lead-region DDL через postgres superuser..."
echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1
else
echo "Таблица phone_ranges уже существует — только помечаю миграцию."
fi
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
sudo -u postgres psql -d liderra -c \
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}')"
echo "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})."
echo "=== Проверка таблиц ==="
sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true
REMOTE
- name: op=set-env (keys from secrets + flag → prod .env)
if: ${{ github.event.inputs.op == 'set-env' }}
env:
DK: ${{ secrets.DADATA_API_KEY }}
DS: ${{ secrets.DADATA_SECRET }}
run: |
DK_B64=$(printf '%s' "$DK" | base64 -w0)
DS_B64=$(printf '%s' "$DS" | base64 -w0)
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
ENV="${APP_DIR}/.env"
DK=$(echo "$DK_B64" | base64 -d)
DS=$(echo "$DS_B64" | base64 -d)
upsert() {
local key="$1" val="$2"
sudo sed -i "/^${key}=/d" "$ENV"
echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null
}
upsert DADATA_API_KEY "$DK"
upsert DADATA_SECRET "$DS"
upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG"
cd "$APP_DIR"
sudo -u www-data php artisan config:clear
sudo -u www-data php artisan config:cache
sudo systemctl restart liderra-queue
echo "set-env готово: flag=${FLAG}, ключи записаны."
echo "=== Проверка (значения скрыты) ==="
sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/'
echo "=== queue status ==="
systemctl is-active liderra-queue || true
REMOTE
- name: op=fetch-rossvyaz (download registry on prod)
if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }}
run: |
# Пустой url → качаем все 4 официальных файла Минцифры за один прогон.
# Непустой url → качаем только его (ручной режим).
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log
set -euo pipefail
DEST=/var/www/liderra/rossvyaz
sudo mkdir -p "$DEST"
cd "$DEST"
if [ -n "$URL" ]; then
URLS="$URL"
else
URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv
https://opendata.digital.gov.ru/downloads/ABC-3xx.csv
https://opendata.digital.gov.ru/downloads/ABC-4xx.csv
https://opendata.digital.gov.ru/downloads/ABC-8xx.csv"
fi
for U in $URLS; do
FNAME=$(basename "${U%%\?*}")
[ -n "$FNAME" ] || FNAME="rossvyaz-download"
echo "Скачиваю $U -> $FNAME"
sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U"
case "$FNAME" in
*.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;;
esac
done
sudo chown -R www-data:www-data "$DEST"
echo "=== Содержимое $DEST ==="
ls -lh "$DEST"
FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true)
if [ -n "$FIRST_CSV" ]; then
echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ==="
sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV"
fi
REMOTE
- name: op=fetch-via-runner (download on runner, ship to prod)
if: ${{ github.event.inputs.op == 'fetch-via-runner' }}
run: |
mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv
for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do
FN=$(basename "${U%%\?*}")
echo "runner: скачиваю $U -> $FN"
curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U"
done
echo "=== скачано на runner ==="
ls -lh /tmp/rv | tee /tmp/op.log
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv'
scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log
- name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there)
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
run: |
# Ищем файлы реестра где угодно (корень или папка), .csv или .zip
mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*')
if [ ${#FILES[@]} -eq 0 ]; then
echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1
fi
echo "=== файлы в репозитории (rossvyaz-data/) ==="
ls -lh "${FILES[@]}" | tee /tmp/op.log
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*'
scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" '
cd /tmp/rvup
for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done
sudo mkdir -p /var/www/liderra/rossvyaz
find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \;
sudo chown -R www-data:www-data /var/www/liderra/rossvyaz
echo "=== на проде /var/www/liderra/rossvyaz ==="
ls -lh /var/www/liderra/rossvyaz
' | tee -a /tmp/op.log
- name: op=import (phone-ranges:import)
if: ${{ github.event.inputs.op == 'import' }}
run: |
DRY_FLAG=""
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
FORCE_FLAG=""
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; fi
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_FLAG 2>&1
echo "=== Счётчики ==="
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
# подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе
# ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE).
STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL")
if [ "$STAGING_EXISTS" = "t" ]; then
sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true
else
echo "staging: отсутствует (после свапа — норма)"
fi
echo "=== Последний импорт ==="
sudo -u postgres psql -d liderra -c \
"SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true
REMOTE
- name: op=smoke (phone-region:smoke)
if: ${{ github.event.inputs.op == 'smoke' }}
run: |
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-region:smoke --phone=${PHONE} ==="
sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1
REMOTE
- name: Print summary
if: always()
run: |
{
echo "## lead-region-ops: \`${OP}\`"
echo
echo '```'
cat /tmp/op.log 2>/dev/null || echo "(нет вывода)"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/liderra_deploy
-69526
View File
File diff suppressed because it is too large Load Diff
-150000
View File
File diff suppressed because it is too large Load Diff
-142791
View File
File diff suppressed because it is too large Load Diff
-73783
View File
File diff suppressed because it is too large Load Diff
+1 -5
View File
File diff suppressed because one or more lines are too long
-16985
View File
File diff suppressed because it is too large Load Diff
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Одноразовый бэкфилл: проставляет deals.city (имя субъекта) у уже существующих сделок,
* у которых city ещё пуст, по resolved_subject_code связанного лида
* (deals supplier_lead_deliveries supplier_leads). Идемпотентно (только city IS NULL).
*
* Запускается через .github/workflows/artisan-run.yml (mutating-whitelist, confirm_apply).
* Парная правка для RouteSupplierLeadJob, который заполняет city у новых сделок.
*/
final class DealsBackfillRegionCityCommand extends Command
{
protected $signature = 'deals:backfill-region-city {--dry-run : Только посчитать, ничего не записывать}';
protected $description = 'Дозаполнить deals.city именем региона по resolved_subject_code лида (одноразовый бэкфилл)';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
// BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
$conn = DB::connection('pgsql_supplier');
$map = RussianRegions::CODE_TO_NAME;
$rows = $conn->table('deals')
->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
->whereNull('deals.city')
->whereNotNull('sl.resolved_subject_code')
->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
->get();
$seen = [];
$updated = 0;
foreach ($rows as $r) {
$dealId = (int) $r->id;
if (isset($seen[$dealId])) {
continue; // у сделки несколько доставок — обрабатываем один раз
}
$seen[$dealId] = true;
$name = $map[(int) $r->resolved_subject_code] ?? null;
if ($name === null) {
continue; // код вне справочника 1..89 — пропускаем
}
if (! $dryRun) {
$conn->table('deals')
->where('id', $dealId)
->where('received_at', $r->received_at) // partition key
->whereNull('city') // идемпотентный страж
->update(['city' => $name]);
}
$updated++;
}
$prefix = $dryRun ? '[dry-run] ' : '';
$this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
Log::info('deals.backfill_region_city', [
'updated' => $updated,
'candidates' => count($seen),
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}
@@ -1,445 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
/**
* Импорт реестра нумерации Россвязи в `phone_ranges` (spec §6).
*
* php artisan phone-ranges:import --file=<csv|xlsx> [--force] [--dry-run]
* php artisan phone-ranges:import --dir=<dir с пакетом файлов> [...]
*
* Алгоритм:
* 1. Резолв входных файлов (--file | --dir; --url отложен оператор качает пакет вручную).
* 2. Checksum-идемпотентность: совпал с предыдущим `completed` status='rolled_back', выход.
* 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) нормализованные строки.
* 4. Маппинг region subject_code через RussianRegions::nameToCode(). Несматчившиеся лог в error.
* 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT.
* 6. --dry-run staging остаётся для инспекции, swap НЕ делается, status='rolled_back'.
* Иначе atomic RENAME swap + status='completed'.
*
* Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker член crm_migrator,
* INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test postgres superuser).
*
* NB (swap operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом
* RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным
* импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency.
*/
class PhoneRangesImportCommand extends Command
{
/** @var string */
protected $signature = 'phone-ranges:import
{--file= : Путь к одному CSV/XLSX файлу реестра}
{--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)}
{--url= : (отложено) URL пакета скачать вручную и использовать --dir}
{--force : Игнорировать checksum-идемпотентность}
{--dry-run : Распарсить и собрать staging, но не делать atomic swap}';
/** @var string */
protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)';
/** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */
private const DDL_CONNECTION = 'pgsql_supplier';
/** Размер пачки для bulk INSERT в staging. */
private const INSERT_CHUNK = 1000;
public function handle(): int
{
$files = $this->resolveFiles();
if ($files === null) {
return self::FAILURE;
}
$checksum = $this->computeChecksum($files);
$dryRun = (bool) $this->option('dry-run');
$force = (bool) $this->option('force');
// 2. Идемпотентность по checksum (если не --force).
if (! $force) {
$prev = DB::table('phone_ranges_imports')
->where('checksum_sha256', $checksum)
->where('status', 'completed')
->orderByDesc('id')
->first();
if ($prev !== null) {
DB::table('phone_ranges_imports')->insert([
'source_url' => $this->sourceLabel($files),
'checksum_sha256' => $checksum,
'status' => 'rolled_back',
'rows_inserted' => 0,
'rows_updated' => 0,
'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.",
'imported_at' => now(),
'completed_at' => now(),
]);
$this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта).");
return self::SUCCESS;
}
}
// 3. Журнал импорта (in_progress).
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => $this->sourceLabel($files),
'checksum_sha256' => $checksum,
'status' => 'in_progress',
'imported_at' => now(),
]);
try {
// 4. Парсинг + маппинг.
$unmatched = [];
$rows = [];
foreach ($files as $file) {
foreach ($this->parseFile($file) as $rec) {
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
$subjectCode = $regionNormalized === null
? null
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
if ($subjectCode === null && trim($rec['region']) !== '') {
$unmatched[trim($rec['region'])] = true;
}
$rows[] = [
'def_code' => $rec['def_code'],
'from_num' => $rec['from_num'],
'to_num' => $rec['to_num'],
'operator' => $rec['operator'],
'region' => $rec['region'],
'region_normalized' => $regionNormalized,
'subject_code' => $subjectCode,
'imported_at' => now(),
'import_id' => $importId,
];
}
}
// 5. Сборка staging.
$this->buildStaging($rows, $importId);
$unmatchedNote = $unmatched === []
? ''
: 'Не сопоставлены регионы: '.implode(', ', array_keys($unmatched)).'.';
if ($dryRun) {
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'rolled_back',
'rows_inserted' => count($rows),
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
'completed_at' => now(),
]);
$this->info('dry-run: '.count($rows)." строк в phone_ranges_staging, swap не выполнен.");
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
return self::SUCCESS;
}
// 6. Atomic swap (operator-validated — см. docblock).
$this->atomicSwap();
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'completed',
'rows_inserted' => count($rows),
'error' => $unmatchedNote !== '' ? $unmatchedNote : null,
'completed_at' => now(),
]);
$this->info('Импортировано '.count($rows).' строк в phone_ranges (atomic swap выполнен).');
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
return self::SUCCESS;
} catch (\Throwable $e) {
DB::table('phone_ranges_imports')->where('id', $importId)->update([
'status' => 'failed',
'error' => mb_substr($e->getMessage(), 0, 2000),
'completed_at' => now(),
]);
$this->error('Импорт упал: '.$e->getMessage());
return self::FAILURE;
}
}
/**
* @return list<string>|null Список файлов или null при ошибке валидации опций.
*/
private function resolveFiles(): ?array
{
$file = $this->option('file');
$dir = $this->option('dir');
$url = $this->option('url');
if ($url !== null) {
$this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.');
return null;
}
if ($file !== null) {
if (! is_file($file)) {
$this->error("Файл не найден: {$file}");
return null;
}
return [$file];
}
if ($dir !== null) {
if (! is_dir($dir)) {
$this->error("Каталог не найден: {$dir}");
return null;
}
$found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: [];
if ($found === []) {
$this->error("В каталоге нет *.csv / *.xlsx: {$dir}");
return null;
}
sort($found);
return array_values($found);
}
$this->error('Укажите --file=<путь> или --dir=<каталог>.');
return null;
}
/**
* @param list<string> $files
*/
private function computeChecksum(array $files): string
{
if (count($files) === 1) {
return (string) hash_file('sha256', $files[0]);
}
$hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files);
sort($hashes);
return hash('sha256', implode('|', $hashes));
}
/**
* @param list<string> $files
*/
private function sourceLabel(array $files): string
{
return $this->option('url')
?? $this->option('dir')
?? ($files[0] ?? 'unknown');
}
/**
* Парсит один файл реестра в нормализованные строки.
*
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseFile(string $path): array
{
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return $ext === 'xlsx'
? $this->parseXlsx($path)
: $this->parseCsv($path);
}
/**
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseCsv(string $path): array
{
$content = (string) file_get_contents($path);
// BOM strip + split строк (CRLF/CR/LF).
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
$lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: [];
if ($lines === []) {
return [];
}
$header = str_getcsv((string) array_shift($lines), ';');
$cols = $this->resolveColumns($header);
$out = [];
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$cells = str_getcsv($line, ';');
$rec = $this->mapCells($cells, $cols);
if ($rec !== null) {
$out[] = $rec;
}
}
return $out;
}
/**
* Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом).
*
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
*/
private function parseXlsx(string $path): array
{
$reader = new XlsxReader();
$reader->open($path);
$out = [];
$cols = null;
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$cells = array_map(static fn ($c): string => (string) $c, $row->toArray());
if ($cols === null) {
$cols = $this->resolveColumns($cells);
continue;
}
$rec = $this->mapCells($cells, $cols);
if ($rec !== null) {
$out[] = $rec;
}
}
break; // только первый лист
}
$reader->close();
return $out;
}
/**
* Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback.
*
* @param list<string> $header
* @return array{def:int, from:int, to:int, operator:int, region:int}
*/
private function resolveColumns(array $header): array
{
$cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5];
foreach ($header as $i => $cell) {
$n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? '';
if (str_contains($n, 'def') || str_contains($n, 'авс')) {
$cols['def'] = $i;
} elseif ($n === 'от') {
$cols['from'] = $i;
} elseif ($n === 'до') {
$cols['to'] = $i;
} elseif (str_contains($n, 'оператор')) {
$cols['operator'] = $i;
} elseif (str_contains($n, 'регион')) {
$cols['region'] = $i;
}
}
return $cols;
}
/**
* @param list<string> $cells
* @param array{def:int, from:int, to:int, operator:int, region:int} $cols
* @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null
*/
private function mapCells(array $cells, array $cols): ?array
{
$def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? '');
if ($def === 0) {
return null; // пустая/битая строка
}
return [
'def_code' => $def,
'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'),
'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'),
'operator' => trim((string) ($cells[$cols['operator']] ?? '')),
'region' => trim((string) ($cells[$cols['region']] ?? '')),
];
}
/**
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
*
* id: НЕ копируем серийный default через INCLUDING DEFAULTS он ссылается на
* исходную последовательность phone_ranges, которую atomic-swap уничтожает
* (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
* без default (NOT NULL violation на повторном импорте). Вместо этого даём
* staging собственную последовательность с уникальным по import_id именем,
* OWNED BY колонкой id она переезжает при RENAME и дропается вместе со
* старой таблицей (без коллизий имён и без утечки последовательностей).
*
* @param list<array<string, mixed>> $rows
*/
private function buildStaging(array $rows, int $importId): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
$seq = "phone_ranges_stg_seq_{$importId}";
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
$c->statement("CREATE SEQUENCE {$seq}");
$c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
$c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
$c->table('phone_ranges_staging')->insert($chunk);
}
}
/**
* Atomic swap живого phone_ranges на staging (spec §6.2 шаг 6).
*
* NB: НЕ покрыт автотестом (committing RENAME сломал бы общую тестовую БД).
* Проверяется первым реальным импортом оператора (Session 6 runbook).
* Сохраняет одну предыдущую версию (phone_ranges_old) для `phone-ranges:rollback`.
* GRANT'ы переустанавливаются (RENAME их не переносит); lookup-индекс на новой
* таблице носит имя idx_phone_ranges_staging_lookup (косметика имя занято _old).
*/
private function atomicSwap(): void
{
$c = DB::connection(self::DDL_CONNECTION);
$this->elevate($c);
// Транзакция вокруг свапа (spec §6.2): PostgreSQL поддерживает транзакционный
// DDL, поэтому DROP+RENAME+RENAME+GRANT атомарны. Обрыв процесса между
// переименованиями не оставит phone_ranges несуществующей — откат вернёт
// живую таблицу (раньше 4 авто-коммит-statement'а оставляли окно, в котором
// Россвязь-lookup падал бы до ручного восстановления).
$c->transaction(function () use ($c) {
$c->statement('DROP TABLE IF EXISTS phone_ranges_old CASCADE');
$c->statement('ALTER TABLE phone_ranges RENAME TO phone_ranges_old');
$c->statement('ALTER TABLE phone_ranges_staging RENAME TO phone_ranges');
$c->statement('GRANT SELECT ON phone_ranges TO crm_app_user, crm_supplier_worker');
});
}
/**
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
* отсутствует RESET и работаем как superuser (зеркало миграционного паттерна).
*/
private function elevate(\Illuminate\Database\Connection $c): void
{
try {
$c->statement('SET ROLE crm_migrator');
$canCreate = $c->selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (! $canCreate || ! $canCreate->ok) {
$c->statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
}
}
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\SupplierLead;
use App\Services\LeadRegionResolver;
use App\Support\RussianRegions;
use Illuminate\Console\Command;
/**
* Staging-smoke резолва региона по телефону (spec §9.4): дёргает живой каскад
* DaData Россвязь tag и печатает решение. В БД ничего НЕ пишет.
*
* php artisan phone-region:smoke --phone=79161234567 [--tag=Москва]
*
* Принудительно включает services.dadata.enabled на время прогона (smoke всегда
* проверяет полный каскад, независимо от глобального feature-flag). С реальным
* DADATA_API_KEY делает платный вызов запускать осознанно.
*/
class PhoneRegionSmokeCommand extends Command
{
/** @var string */
protected $signature = 'phone-region:smoke
{--phone= : Телефон в формате 7XXXXXXXXXX}
{--tag= : Регион-тег поставщика (fallback-слой)}';
/** @var string */
protected $description = 'Прогон резолва региона по телефону (DaData→Россвязь→tag) без записи в БД (staging-smoke)';
public function handle(LeadRegionResolver $resolver): int
{
$phone = (string) $this->option('phone');
if ($phone === '') {
$this->error('Укажите --phone=7XXXXXXXXXX');
return self::FAILURE;
}
// Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен.
config(['services.dadata.enabled' => true]);
$lead = new SupplierLead([
'phone' => $phone,
'raw_payload' => ['tag' => (string) $this->option('tag')],
]);
$r = $resolver->resolve($lead);
$region = $r->subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?')
: '—';
$this->info('Телефон: '.$this->maskPhone($phone));
$this->line('Источник: '.$r->source);
$this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')');
$this->line('Оператор: '.($r->phoneOperator ?? '—'));
$this->line('DaData qc: '.($r->qc ?? '—'));
$this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет'));
$this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет'));
$this->line('Длит., мс: '.($r->durationMs ?? '—'));
$this->newLine();
$this->comment('NB: запись в БД НЕ выполнялась (smoke).');
return self::SUCCESS;
}
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
}
+9 -139
View File
@@ -11,22 +11,18 @@ use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\Dto\RegionResolution;
use App\Services\LeadDistributor;
use App\Services\LeadRegionResolver;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\Pd\PdAuditLogger;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use App\Support\RussianRegions;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -132,6 +128,7 @@ class RouteSupplierLeadJob implements ShouldQueue
// Capture original error BEFORE update — $lead->update() mutates
// the in-memory model, so $lead->error after update() returns the
// suffixed value, breaking debug logs (review fix).
// быстрый коммит
$originalError = $lead->error;
$lead->update([
'processed_at' => now(),
@@ -151,27 +148,16 @@ class RouteSupplierLeadJob implements ShouldQueue
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
$lead->update(['supplier_project_id' => $supplier->id]);
// Lead region resolution (§3.11): резолв региона ДО routing-цикла, чтобы HTTP-вызов
// DaData (~150мс) не висел внутри tenant-транзакции. Резолвер — из контейнера (не 7-й
// параметр handle(), чтобы не ломать сигнатуру и существующие вызовы тестов).
// RegionTagResolver остаётся в DI-цепочке резолвера (fallback-слой).
$resolution = app(LeadRegionResolver::class)->resolve($lead);
$lead->update([
'resolved_subject_code' => $resolution->subjectCode,
'region_source' => $resolution->source,
'dadata_qc' => $resolution->qc,
'phone_operator' => $resolution->phoneOperator,
]);
$matched = $router->matchEligibleProjects($supplier);
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
// Каскад по региону (§3.9): exact → all-RF → fallback. NULL subject_code → шаг 1 пропуск.
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
$selected = $distributor->selectRecipients($matched);
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
$createdCount = 0;
$failures = [];
foreach ($selected as $project) {
try {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $resolution)) {
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
$createdCount++;
}
} catch (Throwable $e) {
@@ -192,10 +178,6 @@ class RouteSupplierLeadJob implements ShouldQueue
);
}
// Аудит резолва региона — одна строка на лид (§3.10/§7.1). Fail-safe: сбой записи
// аудит-лога НЕ должен ронять доставку лида (revenue-critical, 30k/сутки).
$this->logRegionResolution($lead, $resolution, $selected);
$lead->update([
'processed_at' => now(),
'deals_created_count' => $createdCount,
@@ -258,14 +240,10 @@ class RouteSupplierLeadJob implements ShouldQueue
Project $project,
NotificationService $notifier,
LedgerService $ledger,
RegionResolution $resolution,
?int $subjectCode,
): bool {
// routing_step проставлен LeadRouter'ом на matched-проекте; захватываем ДО
// переназначения $project = $lockedProject (fresh query без этого атрибута).
$routingStep = (int) ($project->routing_step ?? 1);
try {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $resolution, $routingStep): bool {
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
/** @var Tenant $tenant */
@@ -376,21 +354,10 @@ class RouteSupplierLeadJob implements ShouldQueue
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
// CSV-recovered received_at сохраняем как есть — отличие на минуты
// несущественно, чем риск каскадного DELETE lead_charges.
// §3.12: при merge обновляем регион/оператора, если webhook-резолв из
// источника выше рангом (dadata/rossvyaz), чем tag CSV-восстановления.
// deals не хранит region_source (он на supplier_leads + в журнале), поэтому
// ранг определяем по факту источника: dadata/rossvyaz всегда достовернее
// tag'а, на котором строилась CSV-recovery (RegionResolution::SOURCE_RANK).
$mergeUpdate = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
if (in_array($resolution->source, ['dadata', 'rossvyaz'], true) && $resolution->subjectCode !== null) {
$mergeUpdate['subject_code'] = $resolution->subjectCode;
$mergeUpdate['phone_operator'] = $resolution->phoneOperator;
$mergeUpdate['city'] = RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null;
}
DB::table('deals')
->where('id', $existingMergeable->id)
->where('received_at', $existingMergeable->received_at)
->update($mergeUpdate);
->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
Log::info('supplier_lead.merged_into_csv_recovered', [
'supplier_lead_id' => $lead->id,
@@ -427,13 +394,6 @@ class RouteSupplierLeadJob implements ShouldQueue
? array_values(array_map('strval', $payload['phones']))
: [(string) $lead->phone];
// §3.10: на шаге 3 (запасной канал) регион сделки подменяется на регион
// клиента (первый подписанный субъект из snapshot); настоящий регион —
// в lead_region_resolution_log.actual_subject_code. region_substituted флажит подмену.
$dealSubjectCode = $routingStep < 3
? $resolution->subjectCode
: ($this->pickSubstituteRegion((string) ($snapshot->regions ?? '{}')) ?? $resolution->subjectCode);
$deal = Deal::create([
'tenant_id' => $tenant->id,
'source_crm_id' => $lead->vid,
@@ -442,14 +402,7 @@ class RouteSupplierLeadJob implements ShouldQueue
'phones' => $phones,
'status' => 'new',
'received_at' => $receivedAt,
'subject_code' => $dealSubjectCode,
// «Город» (UI deals.city) — человекочитаемое имя НАСТОЯЩЕГО региона лида
// по резолву (даже если subject_code подменён на шаге 3). NULL → колонка пустая.
'city' => $resolution->subjectCode !== null
? (RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null)
: null,
'phone_operator' => $resolution->phoneOperator,
'region_substituted' => $routingStep === 3,
'subject_code' => $subjectCode,
]);
DB::table('supplier_lead_deliveries')
@@ -547,89 +500,6 @@ class RouteSupplierLeadJob implements ShouldQueue
]);
}
/**
* Аудит резолва региона лида одна строка на лид в lead_region_resolution_log (§7.1).
* Fail-safe: сбой записи (например, отсутствие партиции received_at) логируется warning'ом,
* но НЕ прерывает доставку (revenue-critical). INSERT через pgsql_supplier (GRANT INSERT
* у crm_supplier_worker). Телефон маскируется до INSERT сырой номер в лог не пишется.
*
* @param Collection<int, Project> $selected
*/
private function logRegionResolution(SupplierLead $lead, RegionResolution $resolution, Collection $selected): void
{
try {
$first = $selected->first();
$routingStep = $first !== null ? (int) ($first->routing_step ?? 1) : null;
$substituted = ($routingStep === 3 && $first !== null)
? ($this->pickSubstituteRegion((string) ($first->snapshot_regions ?? '{}')) ?? $resolution->subjectCode)
: null;
$tagCode = app(RegionTagResolver::class)->resolve((string) ($lead->raw_payload['tag'] ?? ''));
DB::connection(self::DB_CONNECTION)->table('lead_region_resolution_log')->insert([
'supplier_lead_id' => $lead->id,
'received_at' => $lead->received_at ?? now(),
'phone_masked' => $this->maskPhone((string) $lead->phone),
'subject_code_resolved' => $resolution->subjectCode,
'subject_code_from_tag' => $tagCode,
'region_source' => $resolution->source,
'dadata_qc' => $resolution->qc,
'dadata_provider' => $resolution->phoneOperator,
'dadata_type' => null,
'dadata_response_masked' => $resolution->dadataResponseMasked !== null
? json_encode($resolution->dadataResponseMasked, JSON_UNESCAPED_UNICODE)
: null,
'rossvyaz_matched' => $resolution->rossvyazMatched,
'actual_subject_code' => $resolution->actualSubjectCode,
'substituted_subject_code' => $substituted,
'routing_step' => $routingStep,
'phone_operator' => $resolution->phoneOperator,
'cache_hit' => $resolution->cacheHit,
'duration_ms' => $resolution->durationMs,
]);
} catch (Throwable $e) {
Log::warning('lead_region_resolution.log_write_failed', [
'supplier_lead_id' => $lead->id,
'exception' => $e->getMessage(),
]);
}
}
/**
* Первый код субъекта из PG INT[]-литерала ('{82,83}' 82; '{}' null) регион клиента
* для подмены на запасном канале (§3.10).
*/
private function pickSubstituteRegion(string $regionsLiteral): ?int
{
return $this->parseSubjectCodes($regionsLiteral)[0] ?? null;
}
/**
* @return list<int> '{82,83}' [82,83]; '{}'/'' []
*/
private function parseSubjectCodes(string $regionsLiteral): array
{
$inner = trim($regionsLiteral, '{}');
if ($inner === '') {
return [];
}
return array_values(array_map('intval', explode(',', $inner)));
}
/**
* Маскирование телефона для лога (§7.1): первые 4 + последние 4 цифры (7916***4567).
*/
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
/**
* Финальный callback после исчерпания всех ретраев ($tries=3).
*
-4
View File
@@ -61,9 +61,6 @@ class Deal extends Model
'is_test',
'received_at',
'deleted_at',
// Lead region resolution (Session 1, 31.05.2026).
'phone_operator',
'region_substituted',
];
protected function casts(): array
@@ -80,7 +77,6 @@ class Deal extends Model
'lead_score' => 'decimal:2',
'phones' => 'array',
'is_test' => 'boolean',
'region_substituted' => 'boolean',
'assigned_at' => 'datetime',
'received_at' => 'datetime',
'created_at' => 'datetime',
-7
View File
@@ -41,11 +41,6 @@ class SupplierLead extends Model
'recovered_from_csv_at',
'deals_created_count',
'error',
// Lead region resolution (Session 1, 31.05.2026) — persistent idempotency + display.
'resolved_subject_code',
'region_source',
'dadata_qc',
'phone_operator',
];
protected function casts(): array
@@ -57,8 +52,6 @@ class SupplierLead extends Model
'recovered_from_csv_at' => 'datetime',
'vid' => 'integer',
'deals_created_count' => 'integer',
'resolved_subject_code' => 'integer',
'dadata_qc' => 'integer',
];
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Support\Facades\Cache;
/**
* Дневной бюджет на платные вызовы DaData (spec §5.3 / §11).
*
* Расход копится в копейках под дневным ключом `phone_resolution:dadata:spent_kopecks:<YYYY-MM-DD>`.
* `Cache::increment` на redis-сторе атомарен (INCRBY) корректно при параллельных
* RouteSupplierLeadJob. Дневной ключ сам обнуляется со сменой даты; TTL 2 дня чистит старые.
*
* При canSpend()=false LeadRegionResolver минует DaData и идёт сразу в Россвязь (spec §3.3).
*/
class DaDataBudgetGuard
{
public function canSpend(): bool
{
$capKopecks = ((int) config('services.dadata.daily_cap_rub', 10000)) * 100;
return $this->spentTodayKopecks() < $capKopecks;
}
public function recordSpend(int $kopecks): void
{
if ($kopecks <= 0) {
return;
}
$key = $this->dailyKey();
Cache::add($key, 0, now()->addDays(2));
Cache::increment($key, $kopecks);
}
public function spentTodayKopecks(): int
{
return (int) Cache::get($this->dailyKey(), 0);
}
private function dailyKey(): string
{
return 'phone_resolution:dadata:spent_kopecks:'.now()->format('Y-m-d');
}
}
@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use RuntimeException;
/**
* Не-2xx ответ DaData (после исчерпания retry) или иная ошибка вызова.
* LeadRegionResolver ловит её и деградирует на Россвязь (spec §3.3).
*/
class DaDataException extends RuntimeException {}
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* HTTP-обёртка над DaData clean/phone (spec §3.6).
*
* POST https://cleaner.dadata.ru/api/v1/clean/phone
* Authorization: Token <key> ; X-Secret: <secret> ; body ["<phone>"]
*
* Retry только на сетевые ошибки и 5xx (4xx сразу DaDataException, без retry).
* Сеть/таймаут после исчерпания retry DaDataTimeoutException; 5xx DaDataException.
* Конвенция клиента зеркалит App\Services\Supplier\SupplierPortalClient (inject HttpFactory).
*/
class DaDataPhoneClient
{
private const URL = 'https://cleaner.dadata.ru/api/v1/clean/phone';
public function __construct(
private readonly HttpFactory $http,
) {}
public function cleanPhone(string $phone): DaDataPhoneResponse
{
$cfg = (array) config('services.dadata');
$timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000));
$attempts = max(1, (int) ($cfg['retries'] ?? 1) + 1);
$apiKey = (string) ($cfg['api_key'] ?? '');
$secret = (string) ($cfg['secret'] ?? '');
$lastException = null;
for ($attempt = 0; $attempt < $attempts; $attempt++) {
try {
$response = $this->http
->asJson()
->acceptJson()
->timeout($timeoutSec)
->withHeaders([
'Authorization' => 'Token '.$apiKey,
'X-Secret' => $secret,
])
->post(self::URL, [$phone]);
} catch (ConnectionException $e) {
$lastException = new DaDataTimeoutException(
'DaData connection failed: '.$e->getMessage(), 0, $e,
);
continue; // сеть → retry
}
if ($response->serverError()) {
$lastException = new DaDataException('DaData 5xx: HTTP '.$response->status());
continue; // 5xx → retry
}
if (! $response->successful()) {
// 4xx — клиентская ошибка, retry бессмыслен.
throw new DaDataException('DaData HTTP '.$response->status().': '.$response->body());
}
return $this->parse($response->json());
}
throw $lastException ?? new DaDataException('DaData failed without a response');
}
/**
* @param mixed $body декодированный JSON (ожидается массив с одним объектом)
*/
private function parse($body): DaDataPhoneResponse
{
$row = (is_array($body) && isset($body[0]) && is_array($body[0])) ? $body[0] : [];
return new DaDataPhoneResponse(
qc: isset($row['qc']) ? (int) $row['qc'] : null,
qcConflict: isset($row['qc_conflict']) ? (int) $row['qc_conflict'] : null,
type: isset($row['type']) ? (string) $row['type'] : null,
phone: isset($row['phone']) ? (string) $row['phone'] : null,
provider: isset($row['provider']) ? (string) $row['provider'] : null,
region: isset($row['region']) ? (string) $row['region'] : null,
city: isset($row['city']) ? (string) $row['city'] : null,
timezone: isset($row['timezone']) ? (string) $row['timezone'] : null,
raw: $row,
);
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Распарсенный ответ DaData clean/phone (один номер один объект), spec §3.6.
*/
final class DaDataPhoneResponse
{
/**
* @param array<string, mixed> $raw полный сырой объект ответа (для маскированного лога)
*/
public function __construct(
public readonly ?int $qc,
public readonly ?int $qcConflict,
public readonly ?string $type,
public readonly ?string $phone,
public readonly ?string $provider,
public readonly ?string $region,
public readonly ?string $city,
public readonly ?string $timezone,
public readonly array $raw,
) {}
}
@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Код качества (`qc`) ответа DaData clean/phone.
*
* Семантика DaData:
* 0 телефон распознан уверенно;
* 1 распознан с допущениями (требует проверки);
* 2 пустой / невозможно распознать;
* 3 несколько телефонов в одном поле;
* 7 иностранный номер.
*
* Решения каскада по qc в LeadRegionResolver (spec §3.4). Enum используется
* для читаемости и tryFrom() при парсинге; необъявленные значения остаются как int.
*/
enum DaDataQualityCode: int
{
case RECOGNIZED = 0;
case ASSUMPTIONS = 1;
case EMPTY = 2;
case MULTIPLE = 3;
case FOREIGN = 7;
}
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Сетевая ошибка / таймаут DaData (после исчерпания retry на сетевые сбои).
* Подкласс DaDataException catch(DaDataException) покрывает оба случая.
*/
class DaDataTimeoutException extends DaDataException {}
-118
View File
@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
use App\Models\SupplierLead;
/**
* Результат резолва региона лида (LeadRegionResolver, spec §3.3).
*
* `subjectCode` итоговый код субъекта (используется маршрутизатором);
* `actualSubjectCode` настоящий резолв (для лога actual_subject_code; на этапе
* резолва равен subjectCode, подмена региона концерн RouteSupplierLeadJob §3.10).
* `source` dadata|rossvyaz|tag|unknown ранг см. SOURCE_RANK (CSV-merge §3.12).
*/
final readonly class RegionResolution
{
/** @var array<string, int> ранг источника для CSV-merge (выше = достовернее) */
public const SOURCE_RANK = [
'dadata' => 4,
'rossvyaz' => 3,
'tag' => 2,
'unknown' => 1,
];
/**
* @param array<string, mixed>|null $dadataResponseMasked
*/
public function __construct(
public ?int $subjectCode,
public ?int $actualSubjectCode,
public string $source,
public ?string $phoneOperator,
public ?int $qc,
public bool $cacheHit,
public ?array $dadataResponseMasked,
public ?int $durationMs,
public bool $rossvyazMatched,
) {}
/**
* @param array<string, mixed>|null $dadataMasked
*/
public static function make(
?int $subjectCode,
string $source,
?string $operator = null,
?int $qc = null,
bool $cacheHit = false,
?array $dadataMasked = null,
?int $durationMs = null,
bool $rossvyazMatched = false,
): self {
return new self(
subjectCode: $subjectCode,
actualSubjectCode: $subjectCode,
source: $source,
phoneOperator: $operator,
qc: $qc,
cacheHit: $cacheHit,
dadataResponseMasked: $dadataMasked,
durationMs: $durationMs,
rossvyazMatched: $rossvyazMatched,
);
}
public static function fromTag(?int $subjectCode): self
{
return self::make($subjectCode, $subjectCode !== null ? 'tag' : 'unknown');
}
/**
* Восстановление из persistent state лида (retry-идемпотентность §3.11) без DaData-вызова.
*/
public static function fromSupplierLead(SupplierLead $lead): self
{
return self::make(
subjectCode: $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
source: (string) ($lead->region_source ?? 'unknown'),
operator: $lead->phone_operator,
qc: $lead->dadata_qc !== null ? (int) $lead->dadata_qc : null,
);
}
public function withCacheHit(bool $hit): self
{
return new self(
subjectCode: $this->subjectCode,
actualSubjectCode: $this->actualSubjectCode,
source: $this->source,
phoneOperator: $this->phoneOperator,
qc: $this->qc,
cacheHit: $hit,
dadataResponseMasked: null, // §3.11: cache-hit лог не несёт masked-ответ
durationMs: $this->durationMs,
rossvyazMatched: $this->rossvyazMatched,
);
}
/**
* Версия для записи в кэш (§7.3): без per-call полей (masked-ответ, длительность, cache-флаг).
*/
public function forCache(): self
{
return new self(
subjectCode: $this->subjectCode,
actualSubjectCode: $this->actualSubjectCode,
source: $this->source,
phoneOperator: $this->phoneOperator,
qc: $this->qc,
cacheHit: false,
dadataResponseMasked: null,
durationMs: null,
rossvyazMatched: $this->rossvyazMatched,
);
}
}
-20
View File
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
/**
* Read-only результат поиска по реестру нумерации Россвязи (`phone_ranges`).
*
* `subjectCode` код субъекта РФ 1..89 (см. App\Support\RussianRegions) либо
* null, если для диапазона он не был промаплен при импорте.
*/
final readonly class RossvyazRecord
{
public function __construct(
public ?int $subjectCode,
public string $region,
public string $operator,
) {}
}
-176
View File
@@ -1,176 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\SupplierLead;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataPhoneResponse;
use App\Services\Dto\RegionResolution;
use App\Support\DaDataRegionMap;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Оркестратор резолва региона лида: DaData Россвязь tag-fallback (spec §3.3, §3.4).
*
* Каскад решений по qc:
* qc 0/3 + region не-ambiguous и маппится source=dadata;
* qc 0/3 + region ambiguous/null/не-маппится Россвязь (оператор от DaData сохраняем, §3.4.1);
* qc 1 / таймаут / 5xx / бюджет исчерпан Россвязь;
* qc 2/7 tag (Россвязь бессмысленна).
* Если ничего не дало код source=tag (или unknown при пустом теге).
*
* Кэш по sha256(phone) (без сырого номера в ключе/значении, §7.3). Persistent-idempotency
* по supplier_leads.resolved_subject_code (защита от двойной оплаты DaData на retry, §3.11).
* Feature-flag services.dadata.enabled=false сразу tag (текущее поведение, §6.5).
*/
class LeadRegionResolver
{
public function __construct(
private readonly DaDataPhoneClient $dadataClient,
private readonly DaDataBudgetGuard $budgetGuard,
private readonly RossvyazPrefixLookup $rossvyazLookup,
private readonly RegionTagResolver $tagResolver,
private readonly CacheRepository $cache,
) {}
public function resolve(SupplierLead $lead): RegionResolution
{
// Feature-flag: резолвер выключен → текущее tag-поведение.
if (! (bool) config('services.dadata.enabled', false)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
// Persistent-idempotency: уже резолвили на предыдущем try → без DaData.
if ($lead->resolved_subject_code !== null || $lead->region_source !== null) {
return RegionResolution::fromSupplierLead($lead);
}
$phone = (string) $lead->phone;
if (! preg_match('/^7\d{10}$/', $phone)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
$cacheKey = $this->cacheKey($phone);
$cached = $this->cache->get($cacheKey);
if ($cached instanceof RegionResolution) {
return $cached->withCacheHit(true);
}
$resolution = $this->doResolve($lead, $phone);
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
$this->cache->put($cacheKey, $resolution->forCache(), now()->addDays($ttlDays));
return $resolution;
}
private function doResolve(SupplierLead $lead, string $phone): RegionResolution
{
$start = microtime(true);
$provider = null;
$qc = null;
$masked = null;
// 1. DaData (под дневным бюджетом).
if ($this->budgetGuard->canSpend()) {
try {
$dadata = $this->dadataClient->cleanPhone($phone);
$this->budgetGuard->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
$qc = $dadata->qc;
$provider = $dadata->provider;
$masked = $this->maskResponse($dadata);
if (in_array($dadata->qc, [0, 3], true)) {
$region = (string) ($dadata->region ?? '');
if ($region !== '' && ! DaDataRegionMap::isAmbiguous($region)) {
$code = DaDataRegionMap::toSubjectCode($region);
if ($code !== null) {
return RegionResolution::make(
$code, 'dadata',
operator: $provider, qc: $qc,
dadataMasked: $masked, durationMs: $this->ms($start),
);
}
// qc=0/3, но регион не маппится → страховка Россвязью.
}
// ambiguous / region=null / не-маппится → Россвязь (provider от DaData сохраняем).
} elseif ($dadata->qc === 2 || $dadata->qc === 7) {
// Мусор / иностранец → Россвязь не поможет, сразу tag.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
// qc=1 → Россвязь.
} catch (DaDataException) {
// Сеть / таймаут / 5xx → деградируем на Россвязь, не падаем.
}
}
// 2. Россвязь.
$rossvyaz = $this->rossvyazLookup->find($phone);
if ($rossvyaz !== null) {
$code = $rossvyaz->subjectCode ?? DaDataRegionMap::toSubjectCode($rossvyaz->region);
if ($code !== null) {
return RegionResolution::make(
$code, 'rossvyaz',
operator: $provider ?? $rossvyaz->operator, // оператор от DaData приоритетнее (MNP)
qc: $qc, dadataMasked: $masked,
durationMs: $this->ms($start), rossvyazMatched: true,
);
}
}
// 3. Tag-fallback.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
private function tagFallback(SupplierLead $lead, ?string $provider, ?int $qc, ?array $masked, ?float $start): RegionResolution
{
$tag = (string) (is_array($lead->raw_payload) ? ($lead->raw_payload['tag'] ?? '') : '');
$tagCode = $this->tagResolver->resolve($tag);
return RegionResolution::make(
$tagCode,
$tagCode !== null ? 'tag' : 'unknown',
operator: $provider,
qc: $qc,
dadataMasked: $masked,
durationMs: $start !== null ? $this->ms($start) : null,
);
}
private function cacheKey(string $phone): string
{
return 'phone-region:'.hash('sha256', $phone);
}
private function ms(float $start): int
{
return (int) round((microtime(true) - $start) * 1000);
}
/**
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
*/
private function maskResponse(DaDataPhoneResponse $response): array
{
$raw = $response->raw;
if (isset($raw['phone']) && is_string($raw['phone'])) {
$raw['phone'] = $this->maskPhone($raw['phone']);
}
return $raw;
}
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
}
+81 -171
View File
@@ -10,219 +10,129 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Random\Randomizer;
/**
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с
* каскадной маршрутизацией по региону (lead region resolution §3.9).
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
*
* Eligibility структурно через snapshot `project_routing_snapshots` за активную
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
* с 21:00 МСК завтрашней. Все эффективные параметры маршрутизации берутся из
* snapshot; из live `projects` только `delivered_today` (остаток лимита),
* из `tenants` `balance_rub` + `frozen_by_balance_at` (live auto-pause).
* с 21:00 МСК завтрашней. Все эффективные параметры маршрутизации
* (daily_limit, delivery_days_mask, regions, signal_type/signal_identifier и т.д.)
* берутся из snapshot. Из live `projects` только `delivered_today` (счётчик
* остатка лимита, обновляется в течение дня) и из `tenants` `balance_rub`
* (live auto-pause при нулевом балансе).
*
* Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона:
* 1) точное совпадение субъекта (`?::int = ANY(snap.regions)`);
* 2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов;
* 3) запасной канал (без фильтра региона) только если первые две пусты;
* сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10).
* Каждый Project помечается атрибутом `routing_step` (1/2/3).
* Это закрывает R-01..R-04, R-06..R-08, R-15 (spec §1.3) клиент Лидерры,
* который paus'нул проект ПОСЛЕ зафиксированного слепка поставщика, всё равно
* получает свои оплаченные лиды по уже зафиксированному slepok'у.
*
* Отбор внутри фазы при кандидатах > cap **взвешенный жребий по остатку лимита**
* (вариант D1=В): шанс остатку, но у каждого кандидата шанс > 0 (вес 1)
* маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз).
* Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937).
* Регион сопоставляется самим supplier_project (тег = субъект) phone-prefix
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
* гарантирован тем, через какой supplier_project пришёл лид.
*
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) в
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
*
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3
* + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9.
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3.
*/
class LeadRouter
{
public function __construct(
private readonly Randomizer $randomizer = new Randomizer,
) {}
/**
* Возвращает cap проектов (по одному на tenant), отобранных каскадом
* по региону + взвешенным жребием. Каждый Project несёт `routing_step`.
* Возвращает ONE project per tenant_id тот, у которого наибольший остаток
* дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
*
* Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
* (клиентам), каждый тенант получает ровно ОДИН проект с наибольшим остатком.
* LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
* а не число проектов, потому что входные данные уже one-per-tenant.
*
* Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) tenant ещё не
* определён, SELECT видит проекты всех tenant'ов.
*
* @return Collection<int, Project>
*/
public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
{
// Активная дата слепка вычисляется в PHP — детерминирована для всего запроса,
// тестируема через Carbon::setTestNow, исключает дрейф между PHP- и DB-часами.
$activeDate = $this->activeSnapshotDate();
$cap = LeadDistributor::CAP;
// Фаза 1: точное совпадение региона (только если резолвер дал subject_code).
$exact = $resolvedSubjectCode !== null
? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, [])
: collect();
$selected = $this->weightedPick($exact, $cap);
$this->tagStep($selected, 1);
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
// match с Лидерра-проектами через snapshot (project_supplier_links для
// DIRECT-row'ов не создаются — DIRECT supplier_projects создаются автоматически
// при получении webhook'а без B-префикса).
if ($supplierProject->platform === 'DIRECT') {
$directSql = <<<'SQL'
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND snap.signal_type = ?
AND LOWER(snap.signal_identifier) = LOWER(?)
AND projects.delivered_today < snap.daily_limit
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
AND tenants.frozen_by_balance_at IS NULL
)
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
$directRows = DB::connection('pgsql_supplier')->select(
$directSql,
[$activeDate, $supplierProject->signal_type, $supplierProject->unique_key]
);
if ($selected->count() >= $cap) {
return $selected->take($cap)->values();
$this->logIfNoSnapshot($directRows, $supplierProject, $activeDate);
return Project::hydrate($directRows)->values();
}
// Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов).
$allRu = $this->queryCandidates(
$activeDate, $supplierProject, 'all_ru', null,
$selected->pluck('tenant_id')->all(),
);
$pickedRu = $this->weightedPick($allRu, $cap - $selected->count());
$this->tagStep($pickedRu, 2);
$combined = $selected->concat($pickedRu);
if ($combined->isNotEmpty()) {
return $combined->take($cap)->values();
}
// Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»).
$fallback = $this->weightedPick(
$this->queryCandidates($activeDate, $supplierProject, 'any', null, []),
$cap,
);
$this->tagStep($fallback, 3);
$this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate);
return $fallback->take($cap)->values();
}
/**
* Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона.
* regionFilter exact|all_ru|any. Возвращает всех eligible (по одному на tenant),
* упорядоченных по остатку лимита DESC, created_at, id; жребий поверх в PHP.
*
* @param list<int> $excludeTenantIds
* @return Collection<int, Project>
*/
private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection
{
$bindings = [$activeDate];
if ($sp->platform === 'DIRECT') {
// DIRECT supplier_projects не имеют pivot — матч по signal_type + identifier.
$sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)';
$bindings[] = $sp->signal_type;
$bindings[] = $sp->unique_key;
} else {
$sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)';
$bindings[] = $sp->id;
}
$regionWhere = '';
if ($regionFilter === 'exact') {
$regionWhere = 'AND ?::int = ANY(snap.regions)';
$bindings[] = $code;
} elseif ($regionFilter === 'all_ru') {
$regionWhere = "AND snap.regions = '{}'::int[]";
}
$excludeWhere = '';
if ($excludeTenantIds !== []) {
$placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?'));
$excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)";
foreach ($excludeTenantIds as $tid) {
$bindings[] = $tid;
}
}
$sql = <<<SQL
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
$sql = <<<'SQL'
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit,
snap.regions AS snapshot_regions
snap.daily_limit AS snapshot_daily_limit
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND $sourceWhere
AND EXISTS (
SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = snap.project_id
AND psl.supplier_project_id = ?
)
AND projects.delivered_today < snap.daily_limit
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
AND tenants.frozen_by_balance_at IS NULL
)
$regionWhere
$excludeWhere
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
$rows = DB::connection('pgsql_supplier')->select($sql, [$activeDate, $supplierProject->id]);
return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings));
$this->logIfNoSnapshot($rows, $supplierProject, $activeDate);
return Project::hydrate($rows)->values();
}
/**
* Взвешенный жребий без возврата (вариант D1=В): отбирает $n кандидатов,
* вероятность остатку лимита, вес 1 у каждого (мелкие не отрезаются).
* При кандидатах $n возвращает всех в исходном SQL-порядке (детерминизм).
* Активная дата слепка по правилу slepok-инварианта:
* до 21:00 МСК сегодняшняя дата;
* с 21:00 МСК завтрашняя.
*
* @param Collection<int, Project> $candidates
* @return Collection<int, Project>
*/
private function weightedPick(Collection $candidates, int $n): Collection
{
if ($n <= 0) {
return collect();
}
$pool = $candidates->values()->all();
if (count($pool) <= $n) {
return collect($pool);
}
$picked = [];
for ($i = 0; $i < $n && $pool !== []; $i++) {
$total = 0;
foreach ($pool as $p) {
$total += $this->weightOf($p);
}
$roll = $this->randomizer->getInt(1, $total);
$acc = 0;
$winner = 0;
foreach ($pool as $idx => $p) {
$acc += $this->weightOf($p);
if ($roll <= $acc) {
$winner = $idx;
break;
}
}
$picked[] = $pool[$winner];
array_splice($pool, $winner, 1);
}
return collect($picked);
}
private function weightOf(Project $project): int
{
$remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today;
return max(1, $remaining);
}
/**
* @param Collection<int, Project> $projects
*/
private function tagStep(Collection $projects, int $step): void
{
foreach ($projects as $project) {
$project->setAttribute('routing_step', $step);
}
}
/**
* Активная дата слепка: до 21:00 МСК сегодня, с 21:00 МСК завтра (§4.2.3).
* Spec §4.2.3.
*/
private function activeSnapshotDate(): string
{
@@ -234,11 +144,11 @@ class LeadRouter
}
/**
* Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки
* snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный
* результат при наличии snapshot'ов не алерт.
* Fail-loud: пишет в лог если по активной дате слепка вообще нет ни одной строки
* snapshot'а это значит, что cron `SnapshotProjectRoutingJob` не отработал.
* (Если строки есть, но ни одна не сматчилась это валидный 0-результат, не алерт.)
*
* @param array<int, mixed> $rows
* @param array<int, object> $rows
*/
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
{
@@ -59,8 +59,6 @@ class MonthlyPartitionManager
'saas_admin_audit_log' => 'created_at',
// Slepok routing (Этап 2, 27.05.2026)
'project_routing_snapshots' => 'snapshot_date',
// Lead region resolution (Session 1, 31.05.2026)
'lead_region_resolution_log' => 'received_at',
];
/**
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Services\Dto\RossvyazRecord;
use Illuminate\Support\Facades\DB;
/**
* Локальный fallback резолва региона/оператора по телефону через реестр
* нумерации Россвязи (`phone_ranges`).
*
* Используется LeadRegionResolver когда DaData недоступна/неуверена (qc=1,
* timeout, бюджет исчерпан). Алгоритм (spec §3.7):
* - def_code = 3 цифры кода ABC/DEF (позиции 1..3 нормализованного номера);
* - subscriber = остаток номера как BIGINT;
* - выбираем самый УЗКИЙ диапазон, накрывающий номер (ORDER BY width ASC),
* т.к. узкие переопределения операторов точнее широких региональных блоков.
*
* Запрос идёт через `pgsql_supplier` (BYPASSRLS на проде, как LeadRouter):
* `phone_ranges` SaaS-level публичные данные без RLS.
*/
class RossvyazPrefixLookup
{
/** Connection для чтения реестра (на проде BYPASSRLS, на dev/test — superuser fallback). */
public const CONNECTION = 'pgsql_supplier';
public function find(string $phone): ?RossvyazRecord
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
// Российский номер: 7|8 + ABC/DEF (3) + абонент (7) = 11 цифр.
if (strlen($digits) !== 11) {
return null;
}
$defCode = (int) substr($digits, 1, 3);
$subscriber = (int) substr($digits, 4);
$row = DB::connection(self::CONNECTION)->selectOne(
'SELECT region, operator, subject_code
FROM phone_ranges
WHERE def_code = ? AND from_num <= ? AND to_num >= ?
ORDER BY (to_num - from_num) ASC
LIMIT 1',
[$defCode, $subscriber, $subscriber],
);
if ($row === null) {
return null;
}
return new RossvyazRecord(
subjectCode: $row->subject_code !== null ? (int) $row->subject_code : null,
region: (string) $row->region,
operator: (string) $row->operator,
);
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Маппинг строки региона из ответа DaData код субъекта РФ (1..89).
*
* DaData возвращает регион в поле `region` (например «Москва», «Московская область»).
* Большинство имён точно совпадают с App\Support\RussianRegions::CODE_TO_NAME;
* расхождения (если найдутся на staging) кладутся в OVERRIDES.
*
* «Объединённые» агломерации («Санкт-Петербург и область») DaData не различает
* город и область внутри поля region. Такие строки помечаются isAmbiguous()
* LeadRegionResolver уходит за точным subject_code в Россвязь (spec §3.4.1).
*/
final class DaDataRegionMap
{
/**
* Строки-агломерации, по которым нельзя однозначно определить субъект.
* Расширяется по реальным наблюдениям на staging (spec §3.4.1).
*
* @var list<string>
*/
public const AMBIGUOUS_REGIONS = [
'Санкт-Петербург и область',
'Москва и область',
];
/**
* Ручные переопределения для имён DaData, не совпадающих с RussianRegions.
* На старте пуст заполняется по findings со staging-smoke.
*
* @var array<string, int>
*/
public const OVERRIDES = [];
public static function toSubjectCode(string $name): ?int
{
$name = trim($name);
if ($name === '') {
return null;
}
return self::OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null;
}
public static function isAmbiguous(string $name): bool
{
return in_array(trim($name), self::AMBIGUOUS_REGIONS, true);
}
}
-88
View File
@@ -114,97 +114,9 @@ final class RussianRegions
89 => 'Ямало-Ненецкий автономный округ',
];
/**
* Алиасы нестандартных форм реестра Россвязи каноничное имя субъекта.
* Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская»
* перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» спец-форма.
*
* @var array<string, string>
*/
private const REGION_ALIASES = [
'г. Москва' => 'Москва',
'Город Москва' => 'Москва',
'г. Санкт-Петербург' => 'Санкт-Петербург',
'г. Санкт - Петербург' => 'Санкт-Петербург',
'г. Севастополь' => 'Севастополь',
'Республика Саха /Якутия/' => 'Республика Саха (Якутия)',
'Чувашская Республика - Чувашия' => 'Чувашская Республика',
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
'Кемеровская область - Кузбасс область' => 'Кемеровская область',
'Кемеровская область - Кузбасс' => 'Кемеровская область',
];
/** @return array<string, int> name => code (обратный индекс) */
public static function nameToCode(): array
{
return array_flip(self::CODE_TO_NAME);
}
/**
* Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
*
* Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
* (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» «Московская обл.»),
* с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
* Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
* «Москва и Московская область», «г.о. Тольятти») null.
*/
public static function canonicalRegionName(string $raw): ?string
{
$segment = self::lastRegionSegment($raw);
if ($segment === '') {
return null;
}
// ХМАО приходит в множестве форм (em-dash/дефис, «Югра», « АО», капитализация) —
// ловим по двум устойчивым маркерам до общих правил.
if (mb_stripos($segment, 'Ханты') !== false && mb_stripos($segment, 'Мансийск') !== false) {
return 'Ханты-Мансийский автономный округ — Югра';
}
if (isset(self::REGION_ALIASES[$segment])) {
return self::REGION_ALIASES[$segment];
}
// «обл.» → «область»; « АО» → « автономный округ».
$name = (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
$name = (string) preg_replace('/\s+АО$/u', ' автономный округ', $name);
// Дефис с пробелами → длинное тире (эталон: «Республика Северная Осетия — Алания»).
// Безопасно: ни одно каноническое имя не содержит дефис, окружённый пробелами
// (составные имена вроде «Кабардино-Балкарская» используют дефис без пробелов).
$name = str_replace(' - ', ' — ', $name);
if (isset(self::nameToCode()[$name])) {
return $name;
}
// Перевёрнутый порядок «Республика X» → «X Республика» (Удмуртская/Чеченская/
// Чувашская/Кабардино-Балкарская/Карачаево-Черкесская, Донецкая Народная/
// Луганская Народная). Республика-first каноны (Татарстан, Карелия…) уже
// отловлены прямым попаданием выше.
if (preg_match('/^Республика\s+(.+)$/u', $name, $m) === 1) {
$reordered = trim($m[1]).' Республика';
if (isset(self::nameToCode()[$reordered])) {
return $reordered;
}
}
return null;
}
/** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
public static function resolveSubjectCode(string $raw): ?int
{
$name = self::canonicalRegionName($raw);
return $name === null ? null : (self::nameToCode()[$name] ?? null);
}
/** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
private static function lastRegionSegment(string $raw): string
{
$parts = explode('|', $raw);
return trim((string) end($parts));
}
}
-13
View File
@@ -42,17 +42,4 @@ return [
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
],
// DaData phone cleaner — резолв региона лида по телефону (lead region resolution).
// Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки.
'dadata' => [
'api_key' => env('DADATA_API_KEY'),
'secret' => env('DADATA_SECRET'),
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
'retries' => (int) env('DADATA_RETRIES', 1),
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
],
];
@@ -1,169 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// SET ROLE crm_migrator на проде (postgres superuser может SET ROLE).
// На dev/testing crm_migrator не имеет GRANT на public schema → RESET ROLE
// и продолжаем как postgres superuser.
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
DB::unprepared(<<<'SQL'
-- 1. phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаём первым)
CREATE TABLE phone_ranges_imports (
id BIGSERIAL PRIMARY KEY,
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source_url TEXT NOT NULL,
rows_inserted INTEGER NOT NULL DEFAULT 0,
rows_updated INTEGER NOT NULL DEFAULT 0,
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress'
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
error TEXT,
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE phone_ranges_imports IS
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
-- 2. phone_ranges (реестр диапазонов Россвязи; SaaS-level, без RLS публичные данные)
CREATE TABLE phone_ranges (
id BIGSERIAL PRIMARY KEY,
def_code SMALLINT NOT NULL,
from_num BIGINT NOT NULL,
to_num BIGINT NOT NULL,
operator TEXT NOT NULL,
region TEXT NOT NULL,
region_normalized TEXT,
subject_code SMALLINT,
imported_at TIMESTAMPTZ NOT NULL,
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
);
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
COMMENT ON TABLE phone_ranges IS
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver. Обновляется ежемесячным cron-импортом.';
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log)
CREATE TABLE lead_region_resolution_log (
id BIGSERIAL,
supplier_lead_id BIGINT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
phone_masked TEXT NOT NULL,
subject_code_resolved SMALLINT,
subject_code_from_tag SMALLINT,
region_source TEXT NOT NULL
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
dadata_qc SMALLINT,
dadata_provider TEXT,
dadata_type TEXT,
dadata_response_masked JSONB,
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
actual_subject_code SMALLINT
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
substituted_subject_code SMALLINT
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
routing_step SMALLINT
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
phone_operator TEXT,
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
duration_ms INTEGER,
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, received_at)
) PARTITION BY RANGE (received_at);
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
COMMENT ON TABLE lead_region_resolution_log IS
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).';
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
-- Стартовые партиции (далее их подхватывает partitions:create-months после Task 1.2).
CREATE TABLE lead_region_resolution_log_y2026_m05
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE lead_region_resolution_log_y2026_m06
PARTITION OF lead_region_resolution_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- 4. supplier_leads: +4 колонки (denormalized display + persistent idempotency для retry).
ALTER TABLE supplier_leads
ADD COLUMN resolved_subject_code SMALLINT
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
ADD COLUMN region_source TEXT
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
ADD COLUMN dadata_qc SMALLINT,
ADD COLUMN phone_operator TEXT;
-- 5. deals: +2 колонки (UI-карточка + флаг подмены региона).
ALTER TABLE deals
ADD COLUMN phone_operator TEXT,
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
SQL);
// Регистрация retention для lead_region_resolution_log (system_settings, 12 месяцев ≈ 365 дней).
$exists = DB::table('system_settings')
->where('key', 'partition_retention_months_lead_region_resolution_log')
->exists();
if (! $exists) {
DB::table('system_settings')->insert([
'key' => 'partition_retention_months_lead_region_resolution_log',
'value' => '12',
'type' => 'int',
'description' => 'Retention в месяцах для lead_region_resolution_log (~365 дней)',
'updated_at' => now(),
]);
}
}
public function down(): void
{
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
DB::unprepared(<<<'SQL'
ALTER TABLE deals
DROP COLUMN IF EXISTS phone_operator,
DROP COLUMN IF EXISTS region_substituted;
ALTER TABLE supplier_leads
DROP COLUMN IF EXISTS resolved_subject_code,
DROP COLUMN IF EXISTS region_source,
DROP COLUMN IF EXISTS dadata_qc,
DROP COLUMN IF EXISTS phone_operator;
DROP TABLE IF EXISTS lead_region_resolution_log CASCADE;
DROP TABLE IF EXISTS phone_ranges CASCADE;
DROP TABLE IF EXISTS phone_ranges_imports CASCADE;
SQL);
DB::table('system_settings')
->where('key', 'partition_retention_months_lead_region_resolution_log')
->delete();
}
};
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/**
* Сеет сделку (city=NULL по умолчанию) + лид с resolved_subject_code + связь
* supplier_lead_deliveries. Возвращает [tenantId, dealId].
*
* @return array{0: int, 1: int}
*/
function seedDealWithResolvedLead(?int $resolvedCode, ?string $city = null): array
{
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site',
'signal_identifier' => 'backfill-city.ru',
'is_active' => true,
]);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$deal = Deal::create([
'tenant_id' => $tenant->id,
'project_id' => $project->id,
'phone' => '79161234567',
'phones' => ['79161234567'],
'status' => 'new',
'received_at' => now(),
'subject_code' => $resolvedCode,
'city' => $city,
]);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
$lead = SupplierLead::factory()->create([
'platform' => 'B1',
'phone' => '79161234567',
'resolved_subject_code' => $resolvedCode,
'region_source' => $resolvedCode !== null ? 'dadata' : 'unknown',
]);
DB::connection('pgsql_supplier')->table('supplier_lead_deliveries')->insert([
'supplier_lead_id' => $lead->id,
'tenant_id' => $tenant->id,
'deal_id' => $deal->id,
'created_at' => now(),
]);
return [$tenant->id, $deal->id];
}
function dealCity(int $dealId): ?string
{
// BYPASSRLS чтение (как и сам бэкфилл) — без tenant-контекста.
return DB::connection('pgsql_supplier')->table('deals')->where('id', $dealId)->value('city');
}
it('backfills deal city from the lead resolved region code', function (): void {
[, $dealId] = seedDealWithResolvedLead(29); // 29 → Красноярский край
$this->artisan('deals:backfill-region-city')->assertSuccessful();
expect(dealCity($dealId))->toBe('Красноярский край');
});
it('does not touch deals that already have a city', function (): void {
[, $dealId] = seedDealWithResolvedLead(29, city: 'Уже стоит');
$this->artisan('deals:backfill-region-city')->assertSuccessful();
expect(dealCity($dealId))->toBe('Уже стоит');
});
it('dry-run reports candidates without writing', function (): void {
[, $dealId] = seedDealWithResolvedLead(29);
$this->artisan('deals:backfill-region-city', ['--dry-run' => true])->assertSuccessful();
expect(dealCity($dealId))->toBeNull();
});
it('leaves city null when the lead has no resolved region', function (): void {
[, $dealId] = seedDealWithResolvedLead(null);
$this->artisan('deals:backfill-region-city')->assertSuccessful();
expect(dealCity($dealId))->toBeNull();
});
@@ -1,124 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function rossvyazFixture(): string
{
return base_path('tests/Fixtures/rossvyaz/sample.csv');
}
it('dry-run parses csv, maps regions to subject_code, builds staging, does not swap', function (): void {
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
->assertSuccessful();
// Staging построен (dry-run не свапает и не дропает staging — данные видны в той же tx).
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
$r495 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 495');
$r921 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 921');
$r999 = DB::selectOne('SELECT subject_code FROM phone_ranges_staging WHERE def_code = 999');
expect((int) $r495->subject_code)->toBe(82) // Москва
->and((int) $r921->subject_code)->toBe(83) // Санкт-Петербург
->and($r999->subject_code)->toBeNull(); // Атлантида — не маппится
// Живой phone_ranges не тронут (свапа не было).
expect(DB::table('phone_ranges')->count())->toBe(0);
// Журнал импорта: dry-run → rolled_back, несматчившийся регион в error.
$imp = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
expect($imp->status)->toBe('rolled_back')
->and($imp->error)->toContain('Атлантида');
});
it('maps all matched rows and counts unmatched separately', function (): void {
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
->assertSuccessful();
$matched = DB::table('phone_ranges_staging')->whereNotNull('subject_code')->count();
$unmatched = DB::table('phone_ranges_staging')->whereNull('subject_code')->count();
expect($matched)->toBe(2)->and($unmatched)->toBe(1);
});
it('skips swap when checksum matches a completed import (idempotency)', function (): void {
$checksum = hash_file('sha256', rossvyazFixture());
DB::table('phone_ranges_imports')->insert([
'source_url' => 'https://rossvyaz.gov.ru/prev',
'checksum_sha256' => $checksum,
'status' => 'completed',
'imported_at' => now(),
'completed_at' => now(),
]);
// Не dry-run: но checksum совпал с completed → короткое замыкание ДО свапа.
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture()])
->assertSuccessful();
expect(DB::table('phone_ranges')->count())->toBe(0); // свапа не было
$latest = DB::table('phone_ranges_imports')->orderByDesc('id')->first();
expect($latest->status)->toBe('rolled_back');
});
it('force flag bypasses idempotency note even with matching checksum', function (): void {
// С --dry-run + --force: идемпотентность игнорируется, но dry-run всё равно не свапает.
$checksum = hash_file('sha256', rossvyazFixture());
DB::table('phone_ranges_imports')->insert([
'source_url' => 'https://rossvyaz.gov.ru/prev',
'checksum_sha256' => $checksum,
'status' => 'completed',
'imported_at' => now(),
'completed_at' => now(),
]);
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true, '--force' => true])
->assertSuccessful();
// --force обошёл idempotency → staging построен заново (3 строки), но dry-run не свапнул.
expect(DB::table('phone_ranges_staging')->count())->toBe(3);
expect(DB::table('phone_ranges')->count())->toBe(0);
});
it('normalizes real Россвязь region formats to subject_code and fills region_normalized', function (): void {
// Форматы из реального прод-реестра (топ unmapped 02.06.2026): префикс «г. »,
// pipe-сегмент региона, сокращение «обл.», перевёрнутая «Республика Удмуртская»,
// и безнадёжный city-only «г.о. Тольятти». def-коды 3-значные (chk_phone_ranges_def_code 300-999).
$this->artisan('phone-ranges:import', ['--file' => base_path('tests/Fixtures/rossvyaz/messy.csv'), '--dry-run' => true])
->assertSuccessful();
$moscow = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 495');
$orenburg = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 922');
$udmurtia = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 987');
$togliatti = DB::selectOne('SELECT subject_code, region_normalized FROM phone_ranges_staging WHERE def_code = 902');
expect((int) $moscow->subject_code)->toBe(82)
->and($moscow->region_normalized)->toBe('Москва')
->and((int) $orenburg->subject_code)->toBe(62)
->and($orenburg->region_normalized)->toBe('Оренбургская область')
->and((int) $udmurtia->subject_code)->toBe(21)
->and($udmurtia->region_normalized)->toBe('Удмуртская Республика')
->and($togliatti->subject_code)->toBeNull()
->and($togliatti->region_normalized)->toBeNull();
});
it('rebuilds staging id even after the live id default was dropped (post-swap state)', function (): void {
// После первого atomic-swap исходная id-последовательность уничтожается
// (DROP phone_ranges_old CASCADE), и live.id остаётся без DEFAULT. Повторный
// импорт обязан выдать staging.id из собственной последовательности, а не упасть
// на NOT NULL. Симулируем это, сняв default у phone_ranges.id.
DB::connection('pgsql_supplier')->statement('ALTER TABLE phone_ranges ALTER COLUMN id DROP DEFAULT');
$this->artisan('phone-ranges:import', ['--file' => rossvyazFixture(), '--dry-run' => true])
->assertSuccessful();
expect(DB::table('phone_ranges_staging')->count())->toBe(3)
->and(DB::table('phone_ranges_staging')->whereNull('id')->count())->toBe(0);
});
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
config([
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
});
it('phone-region:smoke prints the resolution and writes nothing to DB', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
$this->artisan('phone-region:smoke', ['--phone' => '79161234567'])
->assertSuccessful()
->expectsOutputToContain('dadata')
->expectsOutputToContain('Москва');
// Smoke не пишет в БД.
expect(DB::table('lead_region_resolution_log')->count())->toBe(0);
expect(DB::table('deals')->count())->toBe(0);
});
it('phone-region:smoke fails without --phone', function (): void {
$this->artisan('phone-region:smoke')->assertFailed();
});
@@ -1,229 +0,0 @@
<?php
declare(strict_types=1);
use App\Jobs\RouteSupplierLeadJob;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\Billing\LedgerService;
use App\Services\LeadDistributor;
use App\Services\LeadRouter;
use App\Services\NotificationService;
use App\Services\RegionTagResolver;
use App\Services\SupplierProjects\SupplierProjectResolver;
use Database\Seeders\PricingTierSeeder;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
$this->seed(PricingTierSeeder::class);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
});
function runRegionJob(int $supplierLeadId): void
{
(new RouteSupplierLeadJob($supplierLeadId))->handle(
app(LeadRouter::class),
app(SupplierProjectResolver::class),
app(NotificationService::class),
app(LedgerService::class),
app(LeadDistributor::class),
app(RegionTagResolver::class),
);
}
/**
* Создаёт маршрутизируемый лид: supplier B1 site + tenant с балансом + project + snapshot.
*
* @return array{0: SupplierLead, 1: Project, 2: Tenant, 3: SupplierProject}
*/
function seedRoutableLead(string $regions, string $tag, string $phone, string $key = 'vashinvestor.ru'): array
{
$supplier = SupplierProject::factory()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
]);
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'signal_type' => 'site', 'signal_identifier' => $key,
'is_active' => true, 'delivered_today' => 0, 'delivered_in_month' => 0,
'daily_limit_target' => 100,
]);
linkProjectToSupplier($project, $supplier);
createRoutingSnapshotFromProject($project, dailyLimit: 100, regions: $regions);
$vid = 432176649;
$lead = SupplierLead::factory()->create([
'supplier_project_id' => null,
'platform' => 'B1',
'vid' => $vid,
'phone' => $phone,
'received_at' => now(),
'raw_payload' => [
'vid' => $vid, 'project' => "B1_{$key}", 'tag' => $tag,
'phone' => $phone, 'phones' => [$phone], 'time' => now()->getTimestamp(),
],
]);
return [$lead, $project, $tenant, $supplier];
}
function dealFor(int $tenantId, int $projectId): ?Deal
{
DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'");
$deal = Deal::query()->where('project_id', $projectId)->first();
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
return $deal;
}
it('lead with phone uses dadata region, not the tag', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный', 'phone' => '+7 916 123-45-67',
]], 200)]);
// tag='Санкт-Петербург' (дал бы 83), но телефон резолвится в Москву (82).
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Санкт-Петербург', phone: '79161234567');
runRegionJob($lead->id);
$lead->refresh();
expect($lead->resolved_subject_code)->toBe(82)
->and($lead->region_source)->toBe('dadata')
->and($lead->phone_operator)->toBe('МТС');
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and((int) $deal->subject_code)->toBe(82) // регион из DaData, не из тега (83)
->and((bool) $deal->region_substituted)->toBeFalse()
->and($deal->phone_operator)->toBe('МТС');
});
it('logs exactly one region resolution row per lead', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
[$lead] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
runRegionJob($lead->id);
$rows = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->get();
expect($rows)->toHaveCount(1);
expect($rows->first()->region_source)->toBe('dadata');
// Телефон в логе маскирован (не сырой номер) — §7.1.
expect($rows->first()->phone_masked)->not->toBe('79161234567');
});
it('lead with invalid phone falls back to tag', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
// Невалидный телефон → DaData не дёргается → tag (Москва=82).
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '123');
runRegionJob($lead->id);
$lead->refresh();
expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
Http::assertNothingSent();
});
it('lead with resolver disabled via flag uses tag', function (): void {
config(['services.dadata.enabled' => false]);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
runRegionJob($lead->id);
$lead->refresh();
expect($lead->region_source)->toBe('tag')->and($lead->resolved_subject_code)->toBe(82);
Http::assertNothingSent();
});
it('persistent idempotency: pre-resolved lead does not re-call dadata', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
// Эмулируем предыдущий try: резолв уже персистнут.
$lead->update(['resolved_subject_code' => 83, 'region_source' => 'rossvyaz', 'phone_operator' => 'МегаФон']);
runRegionJob($lead->id);
Http::assertNothingSent(); // §3.11 — нет двойной оплаты DaData
$lead->refresh();
expect($lead->resolved_subject_code)->toBe(83)->and($lead->region_source)->toBe('rossvyaz');
});
it('step-3 fallback substitutes subject_code to client region and flags region_substituted', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
// Лид по Москве (82), но клиент подписан только на Питер (83): точных нет, «вся РФ» нет → шаг 3.
[$lead, $project, $tenant] = seedRoutableLead(regions: '{83}', tag: 'tag', phone: '79161234567');
runRegionJob($lead->id);
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and((int) $deal->subject_code)->toBe(83) // подменён на регион клиента (Питер)
->and((bool) $deal->region_substituted)->toBeTrue();
// Настоящий регион (Москва=82) сохранён в журнале как actual_subject_code.
$log = DB::table('lead_region_resolution_log')->where('supplier_lead_id', $lead->id)->first();
expect((int) $log->actual_subject_code)->toBe(82)
->and((int) $log->substituted_subject_code)->toBe(83);
});
it('csv-merge updates subject_code and operator when webhook resolution outranks tag (dadata)', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200)]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
// CSV-recovered сделка: source_crm_id=null, регион из тега «неправильный» (53 = ЛО).
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
$csvDeal = Deal::create([
'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
'received_at' => now(), 'subject_code' => 53,
]);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
runRegionJob($lead->id);
$merged = dealFor($tenant->id, $project->id);
expect((int) $merged->id)->toBe($csvDeal->id) // merge в существующую, не новая
->and((int) $merged->subject_code)->toBe(82) // обновлено DaData (82) поверх tag (53)
->and($merged->phone_operator)->toBe('МТС')
->and((int) $merged->source_crm_id)->toBe($lead->vid);
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
expect(Deal::query()->where('project_id', $project->id)->count())->toBe(1); // второй сделки нет
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
it('csv-merge does not overwrite subject_code when webhook resolution is tag-level', function (): void {
config(['services.dadata.enabled' => false]); // резолвер выключен → source='tag' (rank не выше CSV-tag)
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'Москва', phone: '79161234567');
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
Deal::create([
'tenant_id' => $tenant->id, 'source_crm_id' => null, 'project_id' => $project->id,
'phone' => '79161234567', 'phones' => ['79161234567'], 'status' => 'new',
'received_at' => now(), 'subject_code' => 53,
]);
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
runRegionJob($lead->id);
$merged = dealFor($tenant->id, $project->id);
expect((int) $merged->subject_code)->toBe(53); // tag не выше tag → регион не тронут
});
@@ -631,35 +631,3 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
// Никаких дублей deals — только один с этим vid.
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
});
it('fills deal city with the resolved region name (UI «Город» column)', function (): void {
\Illuminate\Support\Facades\Http::fake(['cleaner.dadata.ru/*' => \Illuminate\Support\Facades\Http::response([[
'qc' => 0, 'region' => 'Москва', 'provider' => 'МТС',
]], 200)]);
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 100000,
]);
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'tag', phone: '79161234567');
runRouteJob($lead->id);
// deals.city = имя субъекта (RussianRegions::CODE_TO_NAME) по резолву: 82 → «Москва».
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and($deal->city)->toBe('Москва');
});
it('leaves deal city null when region is unknown', function (): void {
config(['services.dadata.enabled' => false]);
// Нераспознанный тег + невалидный телефон → subjectCode null → city пустой.
[$lead, $project, $tenant] = seedRoutableLead(regions: '{82}', tag: 'нераспознаваемый-тег-zzz', phone: '123');
runRouteJob($lead->id);
$deal = dealFor($tenant->id, $project->id);
expect($deal)->not->toBeNull()
->and($deal->city)->toBeNull();
});
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(SharesSupplierPdo::class);
it('creates phone_ranges with lookup columns', function (): void {
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges') AS t")->t)->not->toBeNull();
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges'"))
->pluck('column_name')->all();
expect($cols)->toContain('def_code', 'from_num', 'to_num', 'operator', 'region', 'subject_code', 'import_id');
});
it('creates phone_ranges_imports journal table', function (): void {
expect(DB::selectOne("SELECT to_regclass('public.phone_ranges_imports') AS t")->t)->not->toBeNull();
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'phone_ranges_imports'"))
->pluck('column_name')->all();
expect($cols)->toContain('source_url', 'checksum_sha256', 'status', 'rows_inserted', 'rows_updated');
});
it('creates lead_region_resolution_log as a partitioned table', function (): void {
$partitioned = DB::selectOne(
"SELECT 1 AS ok
FROM pg_partitioned_table pt
JOIN pg_class c ON c.oid = pt.partrelid
WHERE c.relname = 'lead_region_resolution_log'"
);
expect($partitioned)->not->toBeNull();
});
it('adds resolution columns to supplier_leads', function (): void {
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_leads'"))
->pluck('column_name')->all();
expect($cols)->toContain('resolved_subject_code', 'region_source', 'dadata_qc', 'phone_operator');
});
it('adds resolution columns to deals', function (): void {
$cols = collect(DB::select("SELECT column_name FROM information_schema.columns WHERE table_name = 'deals'"))
->pluck('column_name')->all();
expect($cols)->toContain('phone_operator', 'region_substituted');
});
@@ -76,11 +76,10 @@ test('идемпотентность: повторный запуск не па
expect($afterSecond)->toBe($afterFirst);
// Output второго запуска должен сказать «0 created» по всем партиционированным таблицам × 6 месяцев
// (текущий + ahead=5). Число таблиц берём из PARTITIONED_TABLES — тест не ломается при добавлении новых.
$expectedSkipped = count(\App\Services\MonthlyPartitionManager::PARTITIONED_TABLES) * 6;
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
$output = Artisan::output();
expect($output)->toContain("0 created, {$expectedSkipped} skipped");
expect($output)->toContain('0 created, 48 skipped');
});
test('--ahead=0 создаёт только текущий месяц', function () {
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\DaData\DaDataBudgetGuard;
it('allows spend while under the daily cap', function (): void {
config(['services.dadata.daily_cap_rub' => 10]); // 1000 копеек
$guard = app(DaDataBudgetGuard::class);
expect($guard->canSpend())->toBeTrue();
$guard->recordSpend(500);
expect($guard->canSpend())->toBeTrue()
->and($guard->spentTodayKopecks())->toBe(500);
});
it('blocks spend once the daily cap is reached', function (): void {
config(['services.dadata.daily_cap_rub' => 1]); // 100 копеек
$guard = app(DaDataBudgetGuard::class);
$guard->recordSpend(100);
expect($guard->canSpend())->toBeFalse();
});
it('accumulates spend across multiple calls', function (): void {
config(['services.dadata.daily_cap_rub' => 100]);
$guard = app(DaDataBudgetGuard::class);
$guard->recordSpend(30);
$guard->recordSpend(70);
expect($guard->spentTodayKopecks())->toBe(100);
});
it('starts at zero spend for a fresh day', function (): void {
$guard = app(DaDataBudgetGuard::class);
expect($guard->spentTodayKopecks())->toBe(0);
});
@@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataTimeoutException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
it('parses qc=0 mobile response into DTO', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 0, 'qc_conflict' => 0, 'type' => 'Мобильный', 'phone' => '+7 921 555-12-34',
'provider' => 'МегаФон', 'region' => 'Санкт-Петербург и область', 'city' => null, 'timezone' => 'UTC+3',
]], 200)]);
$resp = app(DaDataPhoneClient::class)->cleanPhone('79215551234');
expect($resp->qc)->toBe(0)
->and($resp->provider)->toBe('МегаФон')
->and($resp->region)->toBe('Санкт-Петербург и область')
->and($resp->type)->toBe('Мобильный')
->and($resp->raw)->toBeArray();
});
it('parses qc=3 multiple response', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([[
'qc' => 3, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный',
]], 200)]);
expect(app(DaDataPhoneClient::class)->cleanPhone('79991234567')->qc)->toBe(3);
});
it('sends Token auth, X-Secret header and json-array body', function (): void {
config(['services.dadata.api_key' => 'KEY', 'services.dadata.secret' => 'SEC']);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
app(DaDataPhoneClient::class)->cleanPhone('79161234567');
Http::assertSent(function ($request): bool {
return $request->url() === 'https://cleaner.dadata.ru/api/v1/clean/phone'
&& $request->hasHeader('Authorization', 'Token KEY')
&& $request->hasHeader('X-Secret', 'SEC')
&& $request->body() === '["79161234567"]';
});
});
it('throws DaDataTimeoutException on connection error', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
->toThrow(DaDataTimeoutException::class);
});
it('throws DaDataException on persistent 5xx', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response('upstream error', 500)]);
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79215551234'))
->toThrow(DaDataException::class);
});
it('retries once on 5xx then succeeds', function (): void {
Http::fakeSequence('cleaner.dadata.ru/*')
->push('upstream error', 500)
->push([['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']], 200);
$resp = app(DaDataPhoneClient::class)->cleanPhone('79161234567');
expect($resp->qc)->toBe(0);
Http::assertSentCount(2);
});
it('does not retry on 4xx client error', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response('bad request', 400)]);
expect(fn () => app(DaDataPhoneClient::class)->cleanPhone('79161234567'))
->toThrow(DaDataException::class);
Http::assertSentCount(1);
});
@@ -1,215 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use App\Services\LeadRegionResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
config([
'services.dadata.enabled' => true,
'services.dadata.api_key' => 'k',
'services.dadata.secret' => 's',
'services.dadata.daily_cap_rub' => 10000,
]);
});
function resolverSeedImport(): int
{
return (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'test', 'checksum_sha256' => str_repeat('b', 64),
'status' => 'completed', 'imported_at' => now(),
]);
}
function resolverSeedRange(int $subject, string $region = 'Москва', int $def = 916, string $operator = 'Ростелеком'): void
{
DB::table('phone_ranges')->insert([
'def_code' => $def, 'from_num' => 0, 'to_num' => 9999999,
'operator' => $operator, 'region' => $region, 'subject_code' => $subject,
'imported_at' => now(), 'import_id' => resolverSeedImport(),
]);
}
function resolverLead(string $phone = '79161234567', string $tag = ''): SupplierLead
{
return new SupplierLead([
'phone' => $phone,
'raw_payload' => ['tag' => $tag],
'received_at' => now(),
]);
}
function fakeDadata(array $row): void
{
Http::fake(['cleaner.dadata.ru/*' => Http::response([$row], 200)]);
}
it('dadata qc 0 returns dadata source', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС', 'type' => 'Мобильный']);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('dadata')
->and($r->subjectCode)->toBe(82)
->and($r->phoneOperator)->toBe('МТС')
->and($r->qc)->toBe(0)
->and($r->cacheHit)->toBeFalse();
});
it('dadata qc 0 ambiguous region falls to rossvyaz but keeps dadata provider', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Санкт-Петербург и область', 'provider' => 'МегаФон']);
resolverSeedRange(subject: 83, region: 'Санкт-Петербург');
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')
->and($r->subjectCode)->toBe(83)
->and($r->phoneOperator)->toBe('МегаФон') // оператор от DaData (MNP), §3.4.1
->and($r->rossvyazMatched)->toBeTrue();
});
it('dadata qc 3 returns dadata with multiple flag', function (): void {
fakeDadata(['qc' => 3, 'region' => 'Москва', 'provider' => 'МТС']);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('dadata')->and($r->subjectCode)->toBe(82)->and($r->qc)->toBe(3);
});
it('dadata qc 1 falls back to rossvyaz', function (): void {
fakeDadata(['qc' => 1, 'region' => 'Москва', 'provider' => 'Билайн']);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('dadata qc 2 falls back to tag skipping rossvyaz', function (): void {
fakeDadata(['qc' => 2]);
resolverSeedRange(subject: 83); // если бы Россвязь дёрнули — был бы 83
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82)->and($r->rossvyazMatched)->toBeFalse();
});
it('dadata qc 7 falls back to tag skipping rossvyaz', function (): void {
fakeDadata(['qc' => 7]);
resolverSeedRange(subject: 83);
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
});
it('dadata timeout falls back to rossvyaz', function (): void {
Http::fake(fn () => throw new ConnectionException('timeout'));
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('dadata network error 5xx falls back to rossvyaz', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response('err', 500)]);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('budget cap exceeded skips dadata directly to rossvyaz', function (): void {
config(['services.dadata.daily_cap_rub' => 0]); // canSpend() → false
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
Http::assertNothingSent();
});
it('cache hit skips dadata and rossvyaz on the second call', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Москва', 'provider' => 'МТС']);
$resolver = app(LeadRegionResolver::class);
$first = $resolver->resolve(resolverLead());
$second = $resolver->resolve(resolverLead());
expect($first->cacheHit)->toBeFalse()
->and($second->cacheHit)->toBeTrue()
->and($second->subjectCode)->toBe(82);
Http::assertSentCount(1);
});
it('invalid phone skips dadata returns tag', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
$r = app(LeadRegionResolver::class)->resolve(resolverLead(phone: '123', tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
Http::assertNothingSent();
});
it('qc 0 region null falls through to rossvyaz', function (): void {
fakeDadata(['qc' => 0, 'region' => null, 'provider' => 'Tele2']);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82)->and($r->phoneOperator)->toBe('Tele2');
});
it('unmappable dadata region falls through to rossvyaz', function (): void {
fakeDadata(['qc' => 0, 'region' => 'Несуществующий край', 'provider' => 'МТС']);
resolverSeedRange(subject: 82);
$r = app(LeadRegionResolver::class)->resolve(resolverLead());
expect($r->source)->toBe('rossvyaz')->and($r->subjectCode)->toBe(82);
});
it('all three layers fail returns unknown with null subject_code', function (): void {
fakeDadata(['qc' => 1]); // → rossvyaz
// no phone_ranges seeded → rossvyaz miss; tag empty → null
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: ''));
expect($r->source)->toBe('unknown')->and($r->subjectCode)->toBeNull();
});
it('disabled feature flag returns tag without any dadata call', function (): void {
config(['services.dadata.enabled' => false]);
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0]], 200)]);
$r = app(LeadRegionResolver::class)->resolve(resolverLead(tag: 'Москва'));
expect($r->source)->toBe('tag')->and($r->subjectCode)->toBe(82);
Http::assertNothingSent();
});
it('persistent idempotency: already-resolved lead skips dadata', function (): void {
Http::fake(['cleaner.dadata.ru/*' => Http::response([['qc' => 0, 'region' => 'Москва']], 200)]);
$lead = resolverLead();
$lead->resolved_subject_code = 83;
$lead->region_source = 'dadata';
$lead->dadata_qc = 0;
$lead->phone_operator = 'МегаФон';
$r = app(LeadRegionResolver::class)->resolve($lead);
expect($r->subjectCode)->toBe(83)->and($r->source)->toBe('dadata');
Http::assertNothingSent();
});
@@ -1,189 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Services\LeadRouter;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Random\Engine\Mt19937;
use Random\Randomizer;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
/** Детерминированный роутер с засеянным жребием (вариант В). */
function seededRouter(int $seed = 42): LeadRouter
{
return new LeadRouter(new Randomizer(new Mt19937($seed)));
}
/**
* Создаёт tenant + project + pivot/snapshot для каскад-тестов.
* regions PG-массив-литерал ('{82}' / '{}'); remaining лимита = dailyLimit - deliveredToday.
*/
function makeCascadeProject(
SupplierProject $sp,
string $regions,
int $dailyLimit = 100,
int $deliveredToday = 0,
): Project {
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'daily_limit_target' => $dailyLimit,
'delivered_today' => $deliveredToday,
'delivery_days_mask' => 127,
'signal_type' => $sp->signal_type,
'signal_identifier' => $sp->unique_key,
]);
linkProjectToSupplier($project, $sp);
createRoutingSnapshotFromProject(
$project,
signalType: $sp->signal_type,
signalIdentifier: $sp->unique_key,
dailyLimit: $dailyLimit,
regions: $regions,
);
return $project;
}
function b1Supplier(string $key = 'ex.ru'): SupplierProject
{
return SupplierProject::query()->create([
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => $key,
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
}
it('step 1: exact region match wins, others excluded', function (): void {
$sp = b1Supplier();
$spb = makeCascadeProject($sp, regions: '{83}'); // Питер
$msk = makeCascadeProject($sp, regions: '{82}'); // Москва
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$msk->id])
->and($matched->first()->routing_step)->toBe(1);
});
it('step 2: falls to all-RF when no exact match', function (): void {
$sp = b1Supplier('s2.ru');
$allRu = makeCascadeProject($sp, regions: '{}'); // вся РФ
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$allRu->id])
->and($matched->first()->routing_step)->toBe(2);
});
it('step 3: fallback channel when nobody subscribed to region and no all-RF', function (): void {
$sp = b1Supplier('s3.ru');
$spb = makeCascadeProject($sp, regions: '{83}'); // только Питер подписан
// resolvedSubjectCode=82 (Москва): точных нет, «вся РФ» нет → запасной канал.
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$spb->id])
->and($matched->first()->routing_step)->toBe(3);
});
it('exact + all-RF combine up to cap=3, exact taking priority', function (): void {
$sp = b1Supplier('s4.ru');
$e1 = makeCascadeProject($sp, regions: '{82}');
$e2 = makeCascadeProject($sp, regions: '{82}');
$r1 = makeCascadeProject($sp, regions: '{}');
$r2 = makeCascadeProject($sp, regions: '{}');
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
// Всего 3 (cap). Оба точных (step 1) обязаны быть; добор — ровно 1 «вся РФ» (step 2).
expect($matched)->toHaveCount(3);
$byStep = $matched->groupBy(fn ($p) => $p->routing_step);
expect($byStep->get(1)->pluck('id')->sort()->values()->all())->toBe(collect([$e1->id, $e2->id])->sort()->values()->all())
->and($byStep->get(2))->toHaveCount(1);
expect(in_array($byStep->get(2)->first()->id, [$r1->id, $r2->id], true))->toBeTrue();
});
it('null resolvedSubjectCode skips exact, uses all-RF', function (): void {
$sp = b1Supplier('s5.ru');
$allRu = makeCascadeProject($sp, regions: '{}');
$exact = makeCascadeProject($sp, regions: '{82}');
// Резолвер не сработал → шаг 1 пропускается; матчит только «вся РФ».
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: null);
expect($matched->pluck('id')->all())->toBe([$allRu->id])
->and($matched->first()->routing_step)->toBe(2);
});
it('cascade works for DIRECT supplier_project path too', function (): void {
$sp = SupplierProject::query()->create([
'platform' => 'DIRECT', 'signal_type' => 'site', 'unique_key' => 'cashmotor.ru',
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
]);
$msk = makeCascadeProject($sp, regions: '{82}');
$spb = makeCascadeProject($sp, regions: '{83}');
$matched = seededRouter()->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched->pluck('id')->all())->toBe([$msk->id])
->and($matched->first()->routing_step)->toBe(1);
});
it('backward compat: no second arg behaves as all-RF/any (existing call shape)', function (): void {
$sp = b1Supplier('s7.ru');
$allRu = makeCascadeProject($sp, regions: '{}');
// Старая сигнатура (без 2-го аргумента) — дефолт null → шаг 2 all-RF матчит '{}'.
$matched = seededRouter()->matchEligibleProjects($sp);
expect($matched->pluck('id')->all())->toBe([$allRu->id]);
});
it('variant В: weighted pick — small client never starved, big client wins more often', function (): void {
$sp = b1Supplier('fair.ru');
// 5 клиентов на Москву, разный остаток лимита.
$a = makeCascadeProject($sp, regions: '{82}', dailyLimit: 100); // остаток 100
$b = makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
$c = makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
$d = makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
$e = makeCascadeProject($sp, regions: '{82}', dailyLimit: 10); // остаток 10 — самый маленький
$wins = [];
$seedCount = 120;
for ($seed = 0; $seed < $seedCount; $seed++) {
$matched = seededRouter($seed)->matchEligibleProjects($sp, resolvedSubjectCode: 82);
expect($matched)->toHaveCount(3); // лид всегда раздаётся ровно троим
foreach ($matched as $p) {
$wins[$p->id] = ($wins[$p->id] ?? 0) + 1;
}
}
// (1) Мелкого не отрезаем: за 120 розыгрышей хотя бы раз получил лид.
expect($wins[$e->id] ?? 0)->toBeGreaterThan(0);
// (2) Вес уважается: крупный клиент выигрывает строго чаще мелкого.
expect($wins[$a->id] ?? 0)->toBeGreaterThan($wins[$e->id] ?? 0);
});
it('variant В: deterministic — same seed yields same recipients', function (): void {
$sp = b1Supplier('det.ru');
makeCascadeProject($sp, regions: '{82}', dailyLimit: 100);
makeCascadeProject($sp, regions: '{82}', dailyLimit: 50);
makeCascadeProject($sp, regions: '{82}', dailyLimit: 30);
makeCascadeProject($sp, regions: '{82}', dailyLimit: 20);
$first = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
$second = seededRouter(7)->matchEligibleProjects($sp, resolvedSubjectCode: 82)->pluck('id')->all();
expect($first)->toBe($second)->and($first)->toHaveCount(3);
});
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\SupplierLead;
use App\Services\Dto\RegionResolution;
it('exposes the source rank ordering dadata>rossvyaz>tag>unknown', function (): void {
expect(RegionResolution::SOURCE_RANK)->toBe([
'dadata' => 4, 'rossvyaz' => 3, 'tag' => 2, 'unknown' => 1,
]);
});
it('make sets actualSubjectCode equal to subjectCode', function (): void {
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0);
expect($r->subjectCode)->toBe(82)
->and($r->actualSubjectCode)->toBe(82)
->and($r->source)->toBe('dadata')
->and($r->phoneOperator)->toBe('МТС')
->and($r->qc)->toBe(0)
->and($r->cacheHit)->toBeFalse()
->and($r->rossvyazMatched)->toBeFalse();
});
it('fromTag builds a tag-sourced resolution', function (): void {
$r = RegionResolution::fromTag(82);
expect($r->subjectCode)->toBe(82)
->and($r->source)->toBe('tag')
->and($r->phoneOperator)->toBeNull();
});
it('fromSupplierLead reconstructs a persisted resolution (idempotency)', function (): void {
$lead = new SupplierLead([
'resolved_subject_code' => 83,
'region_source' => 'dadata',
'dadata_qc' => 0,
'phone_operator' => 'МегаФон',
]);
$r = RegionResolution::fromSupplierLead($lead);
expect($r->subjectCode)->toBe(83)
->and($r->source)->toBe('dadata')
->and($r->phoneOperator)->toBe('МегаФон')
->and($r->qc)->toBe(0);
});
it('withCacheHit flips the flag and clears the per-call masked response', function (): void {
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => '7916***4567']);
$hit = $r->withCacheHit(true);
expect($hit->cacheHit)->toBeTrue()
->and($hit->subjectCode)->toBe(82)
->and($hit->dadataResponseMasked)->toBeNull();
});
it('forCache strips per-call fields before storing', function (): void {
$r = RegionResolution::make(82, 'dadata', operator: 'МТС', qc: 0, dadataMasked: ['phone' => 'x'], durationMs: 120);
$c = $r->forCache();
expect($c->dadataResponseMasked)->toBeNull()
->and($c->durationMs)->toBeNull()
->and($c->cacheHit)->toBeFalse()
->and($c->subjectCode)->toBe(82)
->and($c->phoneOperator)->toBe('МТС');
});
@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Dto\RossvyazRecord;
use App\Services\RossvyazPrefixLookup;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Вставляет строку-журнал импорта и возвращает её id (import_id для phone_ranges).
*/
function seedRossvyazImport(): int
{
return (int) DB::table('phone_ranges_imports')->insertGetId([
'source_url' => 'https://rossvyaz.gov.ru/test',
'checksum_sha256' => str_repeat('a', 64),
'status' => 'completed',
'imported_at' => now(),
]);
}
/**
* @param array<string, mixed> $overrides
*/
function seedPhoneRange(array $overrides = []): void
{
DB::table('phone_ranges')->insert(array_merge([
'def_code' => 921,
'from_num' => 5550000,
'to_num' => 5559999,
'operator' => 'МегаФон',
'region' => 'Санкт-Петербург',
'subject_code' => 83,
'imported_at' => now(),
'import_id' => seedRossvyazImport(),
], $overrides));
}
it('mobile prefix returns correct region and operator', function (): void {
seedPhoneRange();
$rec = app(RossvyazPrefixLookup::class)->find('79215555123');
expect($rec)->toBeInstanceOf(RossvyazRecord::class)
->and($rec->subjectCode)->toBe(83)
->and($rec->region)->toBe('Санкт-Петербург')
->and($rec->operator)->toBe('МегаФон');
});
it('prefers narrower range when two ranges overlap', function (): void {
$importId = seedRossvyazImport();
// Широкий диапазон (вся 495-зона) — Московская область (56).
seedPhoneRange([
'def_code' => 495, 'from_num' => 1000000, 'to_num' => 9999999,
'operator' => 'Ростелеком', 'region' => 'Московская область',
'subject_code' => 56, 'import_id' => $importId,
]);
// Узкий диапазон внутри — Москва (82). Должен выиграть (ORDER BY width ASC).
seedPhoneRange([
'def_code' => 495, 'from_num' => 2000000, 'to_num' => 2009999,
'operator' => 'МГТС', 'region' => 'Москва',
'subject_code' => 82, 'import_id' => $importId,
]);
$rec = app(RossvyazPrefixLookup::class)->find('74952005000');
expect($rec)->not->toBeNull()
->and($rec->subjectCode)->toBe(82)
->and($rec->region)->toBe('Москва');
});
it('returns null for unknown prefix', function (): void {
seedPhoneRange(); // только def_code=921
expect(app(RossvyazPrefixLookup::class)->find('79991234567'))->toBeNull();
});
it('returns null when subscriber number is outside any range', function (): void {
seedPhoneRange(['def_code' => 921, 'from_num' => 5550000, 'to_num' => 5559999]);
// def_code совпадает (921), но subscriber 4440000 вне [5550000, 5559999]
expect(app(RossvyazPrefixLookup::class)->find('79214440000'))->toBeNull();
});
it('returns null for malformed phone', function (): void {
seedPhoneRange();
expect(app(RossvyazPrefixLookup::class)->find('123'))->toBeNull();
});
+1 -2
View File
@@ -131,7 +131,6 @@ function createRoutingSnapshotFromProject(
string $signalType = 'call',
?string $signalIdentifier = null,
?int $dailyLimit = null,
string $regions = '{}',
): void {
DB::table('project_routing_snapshots')->insert([
'snapshot_date' => $date ?? Carbon::today('Europe/Moscow')->toDateString(),
@@ -139,7 +138,7 @@ function createRoutingSnapshotFromProject(
'tenant_id' => $project->tenant_id,
'daily_limit' => $dailyLimit ?? (int) ($project->effective_daily_limit_today ?? $project->daily_limit_target),
'delivery_days_mask' => (int) ($project->delivery_days_mask ?? 127),
'regions' => $regions,
'regions' => '{}',
'signal_type' => $signalType,
'signal_identifier' => $signalIdentifier,
'sms_senders' => null,
@@ -1,10 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\MonthlyPartitionManager;
it('knows lead_region_resolution_log partition key', function (): void {
expect(MonthlyPartitionManager::PARTITIONED_TABLES)->toHaveKey('lead_region_resolution_log');
expect(MonthlyPartitionManager::PARTITIONED_TABLES['lead_region_resolution_log'])->toBe('received_at');
});
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\DaDataRegionMap;
use App\Support\RussianRegions;
it('maps exact official names via RussianRegions', function (): void {
expect(DaDataRegionMap::toSubjectCode('Москва'))->toBe(82)
->and(DaDataRegionMap::toSubjectCode('Московская область'))->toBe(56)
->and(DaDataRegionMap::toSubjectCode('Санкт-Петербург'))->toBe(83)
->and(DaDataRegionMap::toSubjectCode('Ленинградская область'))->toBe(53);
});
it('trims surrounding whitespace before mapping', function (): void {
expect(DaDataRegionMap::toSubjectCode(' Москва '))->toBe(82);
});
it('flags ambiguous agglomeration strings', function (): void {
expect(DaDataRegionMap::isAmbiguous('Санкт-Петербург и область'))->toBeTrue()
->and(DaDataRegionMap::isAmbiguous('Москва и область'))->toBeTrue()
->and(DaDataRegionMap::isAmbiguous('Москва'))->toBeFalse()
->and(DaDataRegionMap::isAmbiguous('Санкт-Петербург'))->toBeFalse();
});
it('returns null for unmappable region', function (): void {
expect(DaDataRegionMap::toSubjectCode('Атлантида'))->toBeNull()
->and(DaDataRegionMap::toSubjectCode(''))->toBeNull();
});
it('resolves all 89 RussianRegions names', function (): void {
foreach (RussianRegions::CODE_TO_NAME as $code => $name) {
expect(DaDataRegionMap::toSubjectCode($name))->toBe($code);
}
});
@@ -1,102 +0,0 @@
<?php
declare(strict_types=1);
use App\Support\RussianRegions;
/**
* Нормализация регионов реестра Россвязи subject_code.
* Кейсы взяты из реальных топ-50 unmapped-форматов прод-реестра (02.06.2026).
*/
it('maps cities of federal significance with the г. prefix', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Москва'))->toBe(82)
->and(RussianRegions::resolveSubjectCode('г. Санкт-Петербург'))->toBe(83)
->and(RussianRegions::resolveSubjectCode('г. Севастополь'))->toBe(84);
});
it('still maps a plain canonical federal-city name', function (): void {
expect(RussianRegions::resolveSubjectCode('Москва'))->toBe(82);
});
it('takes the last pipe segment as the subject region', function (): void {
// регион = последний сегмент после |
expect(RussianRegions::resolveSubjectCode('г. Оренбург|Оренбургская обл.'))->toBe(62)
->and(RussianRegions::resolveSubjectCode('г. Воскресенск|р-н Воскресенский|Московская обл.'))->toBe(56);
});
it('expands the обл. abbreviation to область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Иркутск|Иркутская обл.'))->toBe(45)
->and(RussianRegions::resolveSubjectCode('г. Балашиха|Московская обл.'))->toBe(56);
});
it('keeps already-canonical край/республика segments', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Красноярск|Красноярский край'))->toBe(29)
->and(RussianRegions::resolveSubjectCode('г. Уфа|Республика Башкортостан'))->toBe(3);
});
it('reorders the Удмуртская Республика inverted form', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Ижевск|Республика Удмуртская'))->toBe(21);
});
it('maps the Кузбасс special form to Кемеровская область', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Кемерово|Кемеровская область - Кузбасс обл.'))->toBe(48);
});
it('returns null for hopeless / ambiguous / city-only strings', function (string $raw): void {
expect(RussianRegions::resolveSubjectCode($raw))->toBeNull();
})->with([
'-',
'Российская Федерация',
'Москва и Московская область', // неоднозначно — два субъекта
'г.о. Тольятти', // нет региона в строке
'г.о. город Уфа',
'',
' ',
]);
it('exposes the canonical name via canonicalRegionName', function (): void {
expect(RussianRegions::canonicalRegionName('г. Оренбург|Оренбургская обл.'))->toBe('Оренбургская область')
->and(RussianRegions::canonicalRegionName('г. Ижевск|Республика Удмуртская'))->toBe('Удмуртская Республика')
->and(RussianRegions::canonicalRegionName('-'))->toBeNull();
});
it('expands the АО abbreviation to автономный округ', function (): void {
expect(RussianRegions::resolveSubjectCode('Ненецкий АО'))->toBe(86)
->and(RussianRegions::resolveSubjectCode('Чукотский АО'))->toBe(88)
->and(RussianRegions::resolveSubjectCode('г. Салехард|Ямало-Ненецкий АО'))->toBe(89);
});
it('maps Ханты-Мансийск variants to ХМАО — Югра', function (): void {
expect(RussianRegions::resolveSubjectCode('г. Сургут|Ханты-Мансийский Автономный округ - Югра АО'))->toBe(87)
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский АО - Югра'))->toBe(87)
->and(RussianRegions::resolveSubjectCode('Ханты-Мансийский Автономный округ - Югра.'))->toBe(87);
});
it('reorders inverted Республика X forms', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Чеченская'))->toBe(23)
->and(RussianRegions::resolveSubjectCode('Республика Кабардино-Балкарская'))->toBe(8)
->and(RussianRegions::resolveSubjectCode('Республика Карачаево-Черкесская'))->toBe(10)
->and(RussianRegions::resolveSubjectCode('Республика Донецкая Народная'))->toBe(6)
->and(RussianRegions::resolveSubjectCode('Республика Луганская Народная'))->toBe(14);
});
it('keeps Республика-first canonical names as-is', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Татарстан'))->toBe(19)
->and(RussianRegions::resolveSubjectCode('Республика Карелия'))->toBe(11);
});
it('handles irregular subject spellings (Саха, Чувашия, Кузбасс)', function (): void {
expect(RussianRegions::resolveSubjectCode('у. Мирнинский|Республика Саха /Якутия/'))->toBe(17)
->and(RussianRegions::resolveSubjectCode('г. Чебоксары|Чувашская Республика - Чувашия'))->toBe(24)
->and(RussianRegions::resolveSubjectCode('Кемеровская область - Кузбасс область'))->toBe(48);
});
it('maps Moscow / SPb spelling variants', function (): void {
expect(RussianRegions::resolveSubjectCode('Город Москва'))->toBe(82)
->and(RussianRegions::resolveSubjectCode('г. Санкт - Петербург'))->toBe(83);
});
it('normalizes spaced hyphen to em-dash (Северная Осетия — Алания)', function (): void {
expect(RussianRegions::resolveSubjectCode('Республика Северная Осетия - Алания'))->toBe(18)
->and(RussianRegions::resolveSubjectCode('г. Владикавказ|Республика Северная Осетия - Алания'))->toBe(18);
});
-5
View File
@@ -1,5 +0,0 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;г. Москва
922;1000000;1099999;100000;ПАО Ростелеком;г. Оренбург|Оренбургская обл.
987;5000000;5099999;100000;ПАО Ростелеком;г. Ижевск|Республика Удмуртская
902;7000000;7009999;10000;ООО Оператор;г.о. Тольятти
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС г. Москва
3 922 1000000 1099999 100000 ПАО Ростелеком г. Оренбург|Оренбургская обл.
4 987 5000000 5099999 100000 ПАО Ростелеком г. Ижевск|Республика Удмуртская
5 902 7000000 7009999 10000 ООО Оператор г.о. Тольятти
-4
View File
@@ -1,4 +0,0 @@
АВС/ DEF;От;До;Емкость;Оператор;Регион
495;2000000;2009999;10000;ОАО МГТС;Москва
921;5550000;5559999;10000;ПАО МегаФон;Санкт-Петербург
999;0000000;0009999;10000;Тест Оператор;Атлантида
1 АВС/ DEF От До Емкость Оператор Регион
2 495 2000000 2009999 10000 ОАО МГТС Москва
3 921 5550000 5559999 10000 ПАО МегаФон Санкт-Петербург
4 999 0000000 0009999 10000 Тест Оператор Атлантида
-8
View File
@@ -1974,11 +1974,3 @@ monitorится
guillemets
mirror'ящий
plan'овский
# Lead region resolution (2026-05-31) — DaData / Rossvyaz region detection
rossvyaz
россвязь
россвязи
dadata
kopecks
qc
+1 -55
View File
@@ -2,61 +2,7 @@
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.40, консолидированная — разворачивает БД с нуля).
## v8.40 (2026-05-31) — lead region resolution (phone_ranges + resolution_log + supplier_leads/deals columns)
Резолюция настоящего региона лида по телефону (DaData → реестр Россвязи → tag-fallback)
и переключение `LeadRouter` на каскадную маршрутизацию по региону. Эта запись покрывает
только схемные изменения Session 1 (таблицы и колонки); бизнес-логика — в последующих сессиях.
Спека: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
План: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
Миграция: `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`.
**Добавлено:**
- **`phone_ranges_imports`** — журнал импортов реестра Россвязи (SaaS-level, без RLS).
Поля: `source_url`, `rows_inserted`/`rows_updated`, `checksum_sha256`, `status`
(`in_progress`/`completed`/`failed`/`rolled_back`), `error`, `completed_at`.
GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
- **`phone_ranges`** — реестр диапазонов нумерации Россвязи (SaaS-level, без RLS — публичные данные).
Поля: `def_code` (код ABC/DEF), `from_num`/`to_num`, `operator`, `region`, `region_normalized`,
`subject_code` (1..89), `imported_at`, `import_id``phone_ranges_imports`. 3 CHECK
(`def_code` 300..999, `subject_code` 1..89, `from_num``to_num`). Индекс
`idx_phone_ranges_lookup (def_code, from_num, to_num)`. GRANT SELECT `crm_app_user` + `crm_supplier_worker`.
- **`lead_region_resolution_log`** — PARTITION BY RANGE (`received_at`), composite PK
`(id, received_at)`. Аудит резолва региона на лид: `phone_masked`, `subject_code_resolved`/
`subject_code_from_tag`, `region_source` (`dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`/
`dadata_provider`/`dadata_type`/`dadata_response_masked` (JSONB), `rossvyaz_matched`,
`actual_subject_code`/`substituted_subject_code` (1..89), `routing_step` (1..3),
`phone_operator`, `cache_hit`, `duration_ms`, `resolved_at`. Индексы `idx_lrrl_lead_id` +
`idx_lrrl_source (region_source, received_at)`. GRANT SELECT,INSERT `crm_supplier_worker` /
SELECT `crm_app_user`. Стартовые партиции `lead_region_resolution_log_y2026_m05`, `_y2026_m06`.
- **`MonthlyPartitionManager::PARTITIONED_TABLES`** +entry `'lead_region_resolution_log' => 'received_at'`.
- **`system_settings`** +key `partition_retention_months_lead_region_resolution_log = '12'` (retention ~365 дней).
**Изменено:**
- **`supplier_leads`** +4 колонки: `resolved_subject_code` (CHECK 1..89), `region_source`
(CHECK `dadata`/`rossvyaz`/`tag`/`unknown`), `dadata_qc`, `phone_operator`. Persistent-idempotency
резолва (retry не повторяет DaData-вызов).
- **`deals`** +2 колонки: `phone_operator`, `region_substituted` BOOLEAN NOT NULL DEFAULT FALSE
(флаг подмены региона на запасном канале — `routing_step` 3).
**NB консолидация:** как и v8.39 (`project_routing_snapshots`), полный DDL живёт в дельта-миграции,
а не в теле `schema.sql` — тело отражает последнюю точку консолидации, заголовок/CHANGELOG ведут
дельты. Свежий деплой: миграция `0001` грузит `schema.sql` → дельта-миграция `2026_05_31` добавляет
эти объекты. Иначе был бы двойной `CREATE TABLE` (0001 + дельта) и `migrate` упал бы.
**NB GRANT'ы:** план Task 1.3 указывал `crm_readonly`, но этой роли на dev/прод нет —
фактические GRANT'ы выданы `crm_app_user` + `crm_supplier_worker` (проверено по `pg_roles`).
**NB 152-ФЗ:** `phone_masked` в логе — маскированный телефон (`7XXX***YYYY`), `dadata_response_masked`
хранит ответ DaData без сырого номера (spec §7.1). Полное `pg_anonymizer`-маскирование —
шаг раскатки (spec §7.2), вне Session 1.
---
**Файл схемы:** `schema.sql` (текущая версия — v8.39, консолидированная — разворачивает БД с нуля).
## v8.39 (2026-05-27) — project_routing_snapshots (Slepok routing Этап 2)
+1 -2
View File
@@ -1,7 +1,6 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.40 (31.05.2026 — lead region resolution Session 1: phone_ranges_imports + phone_ranges (реестр Россвязи, SaaS-level без RLS, idx_phone_ranges_lookup), lead_region_resolution_log (PARTITION BY RANGE (received_at), composite PK (id, received_at), аудит резолва региона на лид), supplier_leads +4 колонки (resolved_subject_code/region_source/dadata_qc/phone_operator), deals +2 колонки (phone_operator/region_substituted). MonthlyPartitionManager +entry, retention 12m. Миграция 2026_05_31_100000, план docs/superpowers/plans/2026-05-29-lead-region-resolution.md. DDL — в дельта-миграции, не в теле (как v8.39))
-- Базовая версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
-- Версия: v8.39 (27.05.2026 — project_routing_snapshots: новая партиционированная таблица снимков маршрутизации (PARTITION BY RANGE (snapshot_date)), composite PK (snapshot_date, project_id), FK tenant_id→tenants, RLS tenant isolation, MonthlyPartitionManager +entry, retention 3m. Slepok routing Этап 2)
-- Базовая версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md)
-- Базовая версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end)
-- Базовая версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
+2 -7
View File
@@ -31,14 +31,9 @@ paths:
keyset (cursor) — O(1) глубины; offset-based — backward-совместимость.
При count_only=true возвращает только {"total": N} без строк.
parameters:
- name: status_in
- name: status_in[]
in: query
description: >
Фильтр по статусам (можно несколько). На проводе сериализуется
Laravel array-binding: status_in[]=NEW&status_in[]=WON. Имя параметра
в спецификации — без скобок: ключи свойств MCP-инструмента обязаны
матчить ^[a-zA-Z0-9_.-]{1,64}$ (скобки запрещены, иначе Anthropic
tools-схема падает с 400).
description: Фильтр по статусам (можно несколько)
required: false
schema:
type: array
+54 -29
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-06-02T10:14:43.123Z
Last updated: 2026-05-30T13:11:39.164Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -8,15 +8,15 @@ Last updated: 2026-06-02T10:14:43.123Z
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
| C4 Сигнальный статус | ✅ | This file (self-reference) |
| C5 Observer-coverage | | 137 episode(s) this month · Stop-hook + post-commit OK |
| C5 Observer-coverage | ⚠️ | 752 episode(s) this month · Stop-hook + post-commit OK · 20 missed activation(s) — see /brain-retro |
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
## Метрики (информационные, не алерты)
- Observer evidence: 137 episodes this month, 0 observer_error markers, 6 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 137
- Last /brain-retro: 2 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 0. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
- Observer evidence: 752 episodes this month, 0 observer_error markers, 186 PII matches before filter
- Legacy v1 episodes (not in factor analysis): 613
- Last /brain-retro: 0 day(s) ago
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 20. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
## Метрики дисциплины
@@ -24,14 +24,16 @@ Baseline дисциплины роутера (этап 2 router discipline overh
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|---|---|---|---|
| planning | 16 | 0.0% | 0.0% |
| feature | 4 | 0.0% | 0.0% |
| analysis | 2 | 0.0% | 0.0% |
| bugfix | 1 | 0.0% | 0.0% |
| analysis | 34 | 23.5% | 14.7% |
| planning | 25 | 12.0% | 16.0% |
| bugfix | 25 | 24.0% | 20.0% |
| feature | 19 | 10.5% | 0.0% |
| cleanup | 6 | 0.0% | 0.0% |
| refactor | 1 | 0.0% | 0.0% |
Router step distribution: 1: 81, 2: 51, 5: 4
Router step distribution: 1: 330, 2: 279, 3: 67, 5: 67
Boundaries applied (ADR / границы): 1 of 136 эпизодов (0.7%).
Boundaries applied (ADR / границы): 76 of 743 эпизодов (10.2%).
## Активные многоэтапные проекты
@@ -43,11 +45,11 @@ Boundaries applied (ADR / границы): 1 of 136 эпизодов (0.7%).
## Длинные сессии
⚠️ Сегодня (2026-06-02 UTC) есть сессии с ≥50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
⚠️ Сегодня (2026-05-30 UTC) есть сессии с ≥50 ходов — корреляция с падением дисциплины роутинга (retro #5 candidate B).
| session_id | макс. ход | % regulated | последний эпизод |
|---|---|---|---|
| `1a9888f8` | 50 | 0% | 2026-06-02T01:43:02.824Z |
| `52b2b52d` | 75 | 3% | 2026-05-30T11:45:39.213Z |
Long sessions correlate with discipline drift. Если % regulated просел в текущей сессии — рассмотри перезапуск.
@@ -55,10 +57,10 @@ Long sessions correlate with discipline drift. Если % regulated просел
| Компонент | Токены (in/out) | USD |
|---|---|---|
| Classifier (Sonnet 4.6) | 10473/50827 | $0.79 |
| Classifier (Sonnet 4.6) | 12550/86494 | $1.34 |
| Self-assessment (Sonnet 4.6) | 0/0 | $0.00 |
| Reviewer (Opus 4.7 + fallback) | 0/0 | $0.00 |
| **Итого** | | **$0.79** |
| **Итого** | | **$1.34** |
## Аномалии классификатора
@@ -71,20 +73,50 @@ Episodes since last run: 542 / threshold: 10
## Reviewer: субагент vs fallback
0 эпизодов проверено из 137.
0 эпизодов проверено из 752.
## Reviewer findings
(нет проверенных эпизодов в текущем периоде)
Проверено: 372 эпизодов. **69 actionable** (wrong_skill + wrong_chain_order).
### error_root_cause
| cause | count |
|---|---:|
| n/a | 271 |
| wrong_skill | 55 |
| external_failure | 28 |
| wrong_chain_order | 14 |
| wrong_tool | 4 |
### Топ alternative_better
| recommended | count |
|---|---:|
| #19 | 18 |
| #25 | 15 |
| #34 | 8 |
| #18 | 8 |
| #33 | 3 |
### node_quality
| judgment | count |
|---|---:|
| disputable | 207 |
| correct | 120 |
| wrong_node | 40 |
| underkill | 3 |
| overkill | 2 |
## Использование override-фраз
⚠️ Превышен порог override-использования сегодня (≥5/день)
| Фраза | За всё время | За сегодня |
|---|---|---|
| `recovery` | 2302 | 0 |
| `без скилов` | 507 | 0 |
| `recovery` | 2302 | 23 ⚠️ |
| `без скилов` | 507 | 40 ⚠️ |
| `ремонт инфраструктуры` | 331 | 0 |
| `срочно` | 225 | 0 |
| `memory dump` | 46 | 0 |
@@ -93,14 +125,7 @@ Episodes since last run: 542 / threshold: 10
## System Health
Топ-3 процессов с CPU > 1ч:
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 10388 | Code | 3.05ч | 1327306.2ч |
| 3220 | MsMpEng | 1.14ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
Долго работающих процессов нет (порог CPU > 1ч).
## Алерт-индикаторы
@@ -0,0 +1,144 @@
# Discipline-guard backlog — router-gate `tools/enforce-*.mjs`
**Worktree:** `.claude/worktrees/discipline-guard` (branch `worktree-discipline-guard`).
**Date:** 2026-05-31. Owner-authorized backlog after quirk-2 + 1A closure (commit `b0cd18d7`).
## Context (already done — do NOT redo)
- **Quirk 2** — redirect detector is quote-aware (`stripQuotedSpans` in `tools/enforce-router-gate.mjs`): `>`/`2>` inside quotes no longer false-blocks. Commit `b0cd18d7`.
- **1A** — removed advertising of dead override phrases (`findOverride` is a v4 stub) from `enforce-prompt-injection` + verify-before-push / coverage-verify / memory-coverage / tdd-gate. Locked by negative tests. Same commit.
- Marketing MCP servers cut from `.mcp.json` (commit `63100dec`).
## Deliberately NOT doing (these are defense lines, not bugs)
- Calibration 6 of the judge (reading chat context) — weakens in-session defense.
- Quirk 3 (loosen exact-match of git approval) — that exact-match is an anti-injection property.
## Backlog (by priority)
### A. `npm ci` in router-gate whitelist (`SAFE_EXACT` in `tools/enforce-router-gate.mjs`) ← current
Restoring locked dependencies is safe and closes worktree-setup friction. `npm ci` installs
exactly the committed lockfile (deterministic, no version drift) — unlike `npm install`/`npm i`,
which stay hard-blacklisted because they can pull new/updated versions.
**TDD:**
1. RED — new describe block in `tools/enforce-router-gate.test.mjs`: allow `npm ci`,
`npm ci --no-audit`, `npm ci --prefer-offline`; still block `npm install`/`npm i`/
`npm install foo`/`npm i foo` (hard-blacklist), `npm cider` (word boundary → default-deny),
`npm ci && rm x` (chain mutating).
2. GREEN — add `/^npm\s+ci\b/` to `SAFE_EXACT` with rationale comment. `\b` prevents
`npm cider`-style prefix matches. Blacklist runs before whitelist, so `npm install`/`npm i`
stay blocked (the `i`-alternative needs `i` right after the space; `npm ci` has `c` there).
3. tools-vitest full run (also the push sentinel).
4. Commit via AskUserQuestion (label = exact command).
### B. Cosmetic path strings in gate messages
`c:/` vs `/c/`, unexpanded `$env:` in gate messages. Polish only.
### F. Parallel-session-lock false cross-worktree collision (2026-05-31, owner-raised)
Symptom: a session in worktree `discipline-guard` was blocked by
`enforce-parallel-session-lock` (held by another session `7f6efd48`, pid changed
12552→19044 across attempts → holder still active; pid is the transient hook-node pid,
session_id is the stable identity).
**Investigation (read-only):**
- Lock keyed by `computeWorkspaceHash(process.cwd())` = md5(cwd).slice(0,12); file
`~/.claude/runtime/session-lock-<hash>.json`; release only on Stop; TTL 5 min.
- 9 lock files accumulated → stale files leak when a session closes without a clean Stop.
- `enforce-branch-switch` read branch "worktree-discipline-guard" via
`git branch --show-current` from `process.cwd()` → the hook's cwd IS the worktree →
**keying is already per-worktree** (NOT coarse main-dir). So the holder shared this
worktree's hash → genuine same-worktree concurrency, the lock working as designed —
NOT a false positive. Do NOT re-key (would weaken same-tree serialization).
**Genuinely-fixable part (no weakening):** leaked lock on close-without-Stop blocks the next
same-worktree session for up to TTL. Fix: release on SessionEnd (not only Stop) + prune
stale lock files on acquire. Ground-truth the lock JSON before coding.
**Closure (2026-05-31).** All keying/hygiene/UX parts done, no discipline weakened:
- **A — keying by worktree root** (`resolveWorkspacePath`, commit `7a469dc9`): keys the
lock on the session's stable `event.cwd` → git toplevel, not the volatile hook
`process.cwd()` (which collapses to main on resume → cross-worktree false-blocks).
Same-worktree serialization unchanged; fallback to `process.cwd()` if `event.cwd` absent.
- **D — clearer block message**: identifies the holder by its STABLE `session_id`; marks
the recorded pid as transient ("may change between attempts"). Chasing the pid was what
led to closing the wrong session. Logic untouched (text only).
- **B — `pruneStaleLocks`**: best-effort delete of leaked lock files that are ALREADY
stale by the shared `isStale()` (now exported — single source of truth). Active
within-TTL locks are never touched → serialization not weakened. Wired into the
PreToolUse branch of `main()`, wrapped so hygiene can never break the gate.
- **C — release on SessionEnd**: NO new code. The existing `!event.tool_name` branch
already releases. To make release fire on session end (not only on Stop turns),
**OWNER ACTION in `.claude/settings.json`**: add `enforce-parallel-session-lock.mjs`
to the `SessionEnd` hook array (it already runs on `Stop`). Pure config; Claude cannot
edit settings.json. Until added, leaked locks are still self-healing via B (prune) +
the 5-min TTL takeover — so this is a reliability nicety, not a correctness gap.
- **E/F — live**: fix is on branch `worktree-discipline-guard`; the live hook executes
from `tools/` on **main**, so it is active only after merge to main. Runtime
effectiveness of A depends on the PreToolUse payload carrying `cwd`; if absent, the
safe fallback = prior behavior (no regression). Verify on main.
### C. TDD-gate cross-actor — chosen: **Z** (full, 2026-05-31; on hold behind F)
`enforce-tdd-gate` does not see test edits made by a subagent (scans only the controller's
own turn; subagent test edit + RED live in `agent-<id>.jsonl`). **Z = Part 1 (close the
projects/ Write hole — verified prerequisite) then Part 2 (read subagent transcript bound to
a Task in this turn).** Condition 1 verified VIOLATED (no Write-tool gate covers
`~/.claude/projects/`), so Variant 1 alone would weaken — safe only bundled with Part 1.
**Closure (2026-05-31, TDD, no discipline weakened — net strengthening):**
- **Part 1**`enforce-runtime-write-deny.mjs` extended with `TRANSCRIPT_RE`
(`(^|/)\.claude/projects/.*\.jsonl$`): the Write tool can no longer create/overwrite any
session/subagent transcript `.jsonl`. Memory files there are `.md` and stay writable
(never match `.jsonl$`). Resolving normalizer blocks `.`/`..` evasion. This makes the
agent-`<id>`.jsonl that Part 2 trusts unforgeable.
- **Part 2**`enforce-tdd-gate.mjs`: `decide()` now also credits a subagent's matching
test edit + RED run via new `subagentEntriesList`. `turnTaskAgentIds(turn)` extracts the
**hex** agentId from the harness-written `Task` tool_result ("agentId: <hex>") — the
controller cannot forge its own tool_result, and the hex-only match blocks
`agentId: ../../x` path-traversal. `subagentTranscriptPaths()` derives
`<dir>/<controller-session>/subagents/agent-<id>.jsonl` (bound to the controller session).
`main()` reads those transcripts best-effort (missing → no extra credit = stricter, never
an error). No NEW weakening: a delegated subagent doing real TDD is legitimate; the only
forgery vector (overwrite the agent jsonl) is closed by Part 1.
- Full tools-vitest: **2027 passed / 2 skipped**.
- **OWNER ACTION (settings.json, Claude can't edit it):** `enforce-tdd-gate.mjs` is already
a registered PreToolUse hook → Part 2 goes live on merge. **Part 1 requires that
`enforce-runtime-write-deny.mjs` be registered** on PreToolUse(Edit|Write|MultiEdit|
NotebookEdit); if it is not yet registered, the transcript Write-deny is inert until added.
### G. Coverage line under-reports cross-turn active skill (2026-05-31, owner-raised)
Symptom: the `coverage: <channel>:<id>` line says `direct`/`chain` when a skill chosen in a
PRIOR turn is still active in the current turn. Root cause: `enforce-coverage-verify.mjs`
credits `channel=skill` only if the `Skill` tool was invoked in the CURRENT turn
(`turnToolUses`). On a continuation turn (skill still active, not re-invoked) an honest
`skill:X` line would be BLOCKED → so the controller learns to under-report as `direct`/`chain`.
**Fix (no weakening):** also credit `skill:X` if X was invoked anywhere earlier in THIS
session (a real `Skill` tool_use in the transcript — still unforgeable). decide() gains a
`priorSkillNames` param; main() collects session-wide Skill names via `sessionToolUses`.
Residual: attribution may be stale (skill invoked long ago) — acceptable; the alternative
(forced dishonest `direct`) is worse, and the owner wants cross-turn skills honored.
### D. Smoke 8 — live Workflow-gate F2 test
Needs a clean session (not code).
### E. H10 — auto-bootstrap worktree (junction node_modules) in `tools/subagent-prompt-prefix.mjs`
### (later) Layer 5 — VM + YubiKey — needs hardware.
## Environment working rules
- Tests / push sentinel: `npx vitest run --root app --config vitest.config.tools.mjs`
(NOT `npm run test:tools` — breaks on keytar). From inside the worktree it's run as
`--root app`; from the main checkout, point `--root` at the worktree app dir.
- Commit: only via AskUserQuestion where the option label = the EXACT command (router-gate
compares verbatim) + plain-language explanation; commit text via `-F` file in `.scratch/`;
commit only explicit paths (parallel sessions).
- Push: needs a fresh verify-sentinel (full run ≤30 min); override phrases are dead
(`findOverride` is a stub) → the only path to push non-`.md` changes is to run the tests.
@@ -1,118 +0,0 @@
# Lead Region Resolution — прогресс автономного прогона (ночь 31.05.2026)
> Хендофф после автономной ночной сессии. Вся работа **на диске в worktree
> `worktree-feat+lead-region-resolution`, НЕ закоммичена** (git commit/push требуют
> approval владельца через гейт — владелец спал). Утром: ревью → коммиты → продолжение.
## Что сделано (Сессии 1–4 — весь движок резолва региона, TDD-зелёный)
| Сессия | Статус | Тесты |
|---|---|---|
| **1** Схема (миграция + партиции + schema.sql sync) | ✅ на диске | 9 passed / 27 assert |
| **2** Россвязь (lookup + DTO + import-команда) | ✅ на диске | 9 passed / 27 assert |
| **3** DaData (region map + config + enum + client + budget guard) | ✅ на диске | 16 passed / 119 assert |
| **4** LeadRegionResolver (оркестратор, 16 кейсов каскада) | ✅ на диске | 16 passed / 46 assert |
| **Консолидированная регрессия** (все файлы вместе) | ✅ | **53 passed / 238 assert** |
### Новые/изменённые файлы
**Создано:**
- `app/database/migrations/2026_05_31_100000_create_phone_ranges_and_resolution_log.php`
- `app/app/Services/RossvyazPrefixLookup.php` + `app/app/Services/Dto/RossvyazRecord.php`
- `app/app/Console/Commands/PhoneRangesImportCommand.php`
- `app/app/Support/DaDataRegionMap.php`
- `app/app/Services/DaData/{DaDataQualityCode,DaDataException,DaDataTimeoutException,DaDataPhoneResponse,DaDataPhoneClient,DaDataBudgetGuard}.php`
- `app/app/Services/Dto/RegionResolution.php`
- `app/app/Services/LeadRegionResolver.php`
- Тесты: `tests/Feature/Migrations/PhoneRangesMigrationTest.php`, `tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php`, `tests/Feature/Services/RossvyazPrefixLookupTest.php`, `tests/Feature/Console/PhoneRangesImportCommandTest.php`, `tests/Unit/Support/DaDataRegionMapTest.php`, `tests/Feature/Services/DaData/{DaDataPhoneClientTest,DaDataBudgetGuardTest}.php`, `tests/Feature/Services/{RegionResolutionTest,LeadRegionResolverTest}.php`
- `tests/Fixtures/rossvyaz/sample.csv`
**Изменено:**
- `app/app/Services/MonthlyPartitionManager.php` — +entry `'lead_region_resolution_log' => 'received_at'`
- `app/app/Models/SupplierLead.php` — +4 колонки в fillable + 2 int-cast
- `app/config/services.php` — +блок `dadata`
- `app/tests/Feature/PartitionsCreateMonthsTest.php` — хрупкий хардкод «48 skipped» → динамический `count(PARTITIONED_TABLES) * 6`
- `db/schema.sql` (v8.39 → **v8.40**, только заголовок) + `db/CHANGELOG_schema.md` (+v8.40)
## Решения, принятые по ходу (для ревью)
1. **Коды субъектов** — по `RussianRegions` (Москва=82, СПб=83, МО=56, ЛО=53), НЕ по спеке (там были авто-коды 77/78/50/47 — неверно).
2. **GRANT'ы миграции**`crm_app_user` + `crm_supplier_worker` (роли `crm_readonly` из плана **не существует**).
3. **`schema.sql`** — только заголовок + CHANGELOG, без тела (как v8.39 project_routing_snapshots): иначе двойной `CREATE TABLE` (0001 грузит schema.sql + дельта-миграция) сломал бы `migrate`.
4. **Размещение тестов** — app/DB-зависимые тесты (DaData-клиент, budget, resolver, DTO с моделью) лежат в **`tests/Feature/...`, не `tests/Unit/...`** как в плане: в проекте `tests/Unit` не бутит Laravel (нет `Http::fake`/`app()`/`Cache`). Чистый `DaDataRegionMap` остался в Unit.
5. **`PhoneRangesImportCommand` swap** — atomic RENAME реализован по спеке, но **committing-swap НЕ покрыт автотестом** (RENAME коммитит и сломал бы общую `liderra_testing`, которую ночью без терминала владельца не пересоздать). Тесты покрывают parse/map/dry-run/idempotency/force. **Свап проверяется первым реальным импортом оператора (Session 6 runbook).** Косметика: lookup-индекс на новой таблице после свапа носит имя `idx_phone_ranges_staging_lookup` (имя `idx_phone_ranges_lookup` занято `phone_ranges_old`).
6. **DaData call cost**`services.dadata.call_cost_kopecks` дефолт 60 (≈0.60 ₽/вызов) — **прикидка, откалибровать по тарифу DaData**.
7. **CSV-парсер импорта** — нативный `str_getcsv(';')` (как проект читает файлы); реальный формат Россвязи (заголовки `АВС/ DEF;От;До;Емкость;Оператор;Регион`, возможно cp1251) уточняется оператором на реальном пакете. XLSX-ветка через openspout — **не протестирована**.
## Что осталось (требует владельца)
### Коммиты (утром, через git-approval)
Предлагаемая разбивка (conventional commits, ветка `worktree-feat+lead-region-resolution`):
- `feat(region): schema migration + MonthlyPartitionManager registration` (миграция, partition manager, PartitionsCreateMonths fix, SupplierLead model, тесты Session 1)
- `chore(region): sync db/schema.sql + CHANGELOG (v8.40)`
- `feat(region): RossvyazPrefixLookup + RossvyazRecord DTO`
- `feat(region): phone-ranges:import command (parse/map/dry-run/idempotency)`
- `feat(region): DaData layer (region map, config, enum, client, budget guard)`
- `feat(region): LeadRegionResolver orchestrator (full qc cascade)`
> NB: коммит-сообщения **без** trailer `Co-Authored-By` — гейт блокирует символ `<` (угловые скобки email). Зафиксировано в `docs/bugs.md`.
### D1 — продуктовое решение ДО Session 5
Сейчас при >3 кандидатах лид раздаётся **3 случайным** клиентам. Каскад (Session 5) раздаёт 3 клиентам с **наибольшим остатком дневного лимита** (детерминированно) — клиент с большим остатком систематически получает больше лидов. Каскад по конструкции (роутер режет до 3 упорядоченно → `LeadDistributor` не шаффлит) **и есть** эта смена. Нужно подтверждение: убрать random — ок? (Если хочешь сохранить случайность внутри региона — это +1 задача: shuffle внутри каждой фазы перед cap.)
### Session 5 (каскад LeadRouter) + Session 6 (интеграция в Job) — после D1
- Зависят от D1 + трогают прод-критичный `RouteSupplierLeadJob` (30k лидов/сутки) → делать с ревью, не вслепую.
- Session 6 Task 6.4 (smoke-команда `phone-region:smoke`) + метрики §8 — отдельно.
### Pre-existing tech debt (не моё, флагую)
- `tests/Feature/Import/MonthlyPartitionManagerTest.php::ensureMonth создаёт партицию webhook_log`**красный независимо от меня**: `webhook_log` удалён из проекта 24.05 (миграция `2026_05_24_140000`), тест не обновили. Можно убрать как наследие отдельным мелким фиксом — на твоё усмотрение.
- `migrate:fresh` на проекте **сломан** (cross-PDO `auth_log` в миграции `0001`): миграция грузит schema.sql на `pgsql`, затем зовёт `partitions:create-months` на `pgsql_supplier` в той же транзакции → невидимость. Тестовая база `liderra_testing` собрана клоном dev (`CREATE DATABASE ... WITH TEMPLATE liderra`), а не через migrate:fresh. Отдельная проблема, вне фичи.
## Как прогнать (из `app/`)
```
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php
```
→ 53 passed / 238 assertions.
---
## ОБНОВЛЕНИЕ 01.06.2026 — Сессии 5–6 реализованы, фича функционально завершена
**D1 решён заказчиком — вариант В** (взвешенный жребий по остатку лимита; мелкие клиенты не отрезаются, вес ≥ 1 у каждого).
| Сессия | Что сделано | Тесты |
|---|---|---|
| **5** LeadRouter каскад (exact→all-RF→fallback) + взвешенный жребий (В) + `routing_step` | `LeadRouter` переписан: `matchEligibleProjects($sp, ?int $resolvedSubjectCode)`, `queryCandidates` (region-фильтр + `snap.regions`), `weightedPick`, инъекция `Randomizer`. Хелпер `createRoutingSnapshotFromProject(+regions)`. | 9 cascade + 10 regression |
| **6.1** Резолв до tx + persist + лог в `RouteSupplierLeadJob` | `app(LeadRegionResolver)->resolve()` (НЕ 7-й параметр handle — чтобы не ломать сигнатуру/тесты), persist 4 колонки, `logRegionResolution` (fail-safe INSERT в журнал через pgsql_supplier, маскированный телефон). | в наборе из 8 |
| **6.2** Подмена subject_code на шаге 3 + `region_substituted` | `createDealCopyForProject(RegionResolution)`, `routing_step` захватывается до `$lockedProject`, `pickSubstituteRegion(snapshot.regions)`. Deal +`phone_operator`/`region_substituted` (model fillable+cast). | в наборе из 8 |
| **6.3** CSV-merge по рангу источника | merge-блок обновляет subject_code/phone_operator если webhook-резолв dadata/rossvyaz (выше tag CSV). **Эвристика**`deals.region_source` нет (документировано). | 2 |
| **6.4** Smoke-команда `phone-region:smoke` | резолв по телефону без записи в БД. **Метрики §8.1 отложены** (нет механизма Prometheus/StatsD в проекте). | 2 |
| **6.5** Финальная регрессия + runbook | **101 passed / 509 assertions** (вся фича + регрессия Job ×3 / Router ×2). Runbook раскатки: `docs/superpowers/runbooks/2026-05-31-lead-region-resolution-rollout.md`. | 101 |
### Новые/изменённые файлы Сессий 5–6 (в worktree, не закоммичено)
- Изменено: `app/app/Services/LeadRouter.php` (каскад + weighted pick + Randomizer), `app/app/Jobs/RouteSupplierLeadJob.php` (resolve+persist+log+substitution+CSV-merge), `app/app/Models/Deal.php` (+2 fillable, +1 cast), `app/tests/Pest.php` (helper +regions).
- Создано: `app/app/Console/Commands/PhoneRegionSmokeCommand.php`; тесты `LeadRouterCascadeTest.php`, `RouteSupplierLeadJobRegionResolutionTest.php`, `PhoneRegionSmokeCommandTest.php`; runbook.
### Решения Сессий 5–6 (для ревью)
1. **D1=В** — взвешенный жребий, мелкие не отрезаны (доказано тестом `variant В: weighted pick` — 120 seed'ов, мелкий выигрывает >0 раз, крупный чаще).
2. **LeadRegionResolver через `app()` внутри `handle()`**, не 7-м параметром — иначе ломались бы сигнатура + 3 существующих Job-теста.
3. **Лог резолва fail-safe** — сбой записи аудит-лога не роняет доставку лида (30k/сутки).
4. **`deals.region_source` НЕ добавлялась** — CSV-merge по рангу через эвристику (dadata/rossvyaz > CSV-tag). Отклонение от плана Task 6.3 (план предполагал колонку), задокументировано.
5. **Метрики §8.1 отложены** — нет механизма метрик в проекте.
### Коммиты Сессий 5–6 (предложение, ветка `worktree-feat+lead-region-resolution`)
- `test(region): createRoutingSnapshotFromProject accepts regions param`
- `feat(region): LeadRouter cascade routing (exact→all-RF→fallback) + weighted pick variant В + routing_step`
- `feat(region): wire LeadRegionResolver into RouteSupplierLeadJob + persist + fail-safe log`
- `feat(region): step-3 region substitution + CSV-merge by source rank`
- `feat(region): phone-region:smoke staging command`
- `docs(region): rollout runbook + session progress`
### Пре-существующий долг (флагую, не моё)
- `tests/Feature/Console/{BillingMigrateLeadsToRub,IncidentsWatchFailures,SnapshotBackfillCommand}Test`**взаимно загрязняются** при прогоне в одном процессе (счётчики растут: ожидал 1, получил 4-5). Падают и БЕЗ моих файлов. В реальном CI (`pest --parallel`, файл = процесс) проходят. Тест-изоляция этих команд хрупкая — отдельная задача.
### Команда финальной регрессии (явный список, из `app/`)
```
vendor/bin/pest tests/Feature/Migrations/PhoneRangesMigrationTest.php tests/Unit/Services/MonthlyPartitionManagerRegionLogTest.php tests/Feature/Services/RossvyazPrefixLookupTest.php tests/Feature/Console/PhoneRangesImportCommandTest.php tests/Feature/Console/PhoneRegionSmokeCommandTest.php tests/Unit/Support/DaDataRegionMapTest.php tests/Feature/Services/DaData tests/Feature/Services/RegionResolutionTest.php tests/Feature/Services/LeadRegionResolverTest.php tests/Feature/Services/LeadRouterTest.php tests/Feature/Services/LeadRouterCascadeTest.php tests/Feature/Jobs/RouteSupplierLeadJobRegionResolutionTest.php tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
```
→ 101 passed / 509 assertions.
@@ -1,61 +0,0 @@
# Россвязь region→subject_code mapping fix — Implementation Plan
> **For agentic workers:** TDD, bite-sized steps. Steps use `- [ ]`.
**Goal:** Маппить регион из реестра Россвязи в `subject_code` через нормализацию форматов, чтобы перестать терять ~98% диапазонов (444904/453080 были NULL из-за exact-match).
**Architecture:** Чистый нормализатор в `App\Support\RussianRegions` (`canonicalRegionName` + `resolveSubjectCode`), unit-тестируемый без БД. `PhoneRangesImportCommand` зовёт его и заполняет `region_normalized`. Прод перечитывает реестр командой `phone-ranges:import` после мержа.
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / PostgreSQL 16.
---
## Корень проблемы (systematic-debugging Phase 1, подтверждён прод-данными)
`PhoneRangesImportCommand` делал `RussianRegions::nameToCode()[trim($rec['region'])]` — exact match. Реальные строки реестра (топ-50 unmapped, прод 02.06.2026):
- `г. Москва` (253342) / `г. Санкт-Петербург` (34573) — города фед. значения с префиксом `г. `
- `г. Оренбург|Оренбургская обл.` — регион = **последний** сегмент после `|`, область сокращена `обл.`
- `г. Воскресенск|р-н Воскресенский|Московская обл.` — 3 сегмента, регион = последний
- `г. Ижевск|Республика Удмуртская` — порядок слов перевёрнут (канон `Удмуртская Республика`)
- `г. Кемерово|Кемеровская область - Кузбасс обл.` — спец-форма
- Безнадёжные (меньшинство, остаются NULL): `-`, `Российская Федерация`, `Москва и Московская область` (неоднозначно), `г.о. Тольятти` / `г.о. город Уфа` (нет региона в строке)
## Правила нормализации
1. Взять последний сегмент после `|`, trim.
2. Прямые алиасы (приоритет): `г. Москва``Москва`, `г. Санкт-Петербург``Санкт-Петербург`, `г. Севастополь``Севастополь`, `Республика Удмуртская``Удмуртская Республика`, `Кемеровская область - Кузбасс обл.``Кемеровская область`.
3. Иначе: суффикс ` обл.`` область`.
4. Результат искать в `nameToCode()`. Нет → `null` (диапазон остаётся unmapped — корректно).
---
## Task 1: `RussianRegions::canonicalRegionName` + `resolveSubjectCode`
**Files:**
- Modify: `app/app/Support/RussianRegions.php`
- Test: `app/tests/Unit/Support/RussianRegionsTest.php`
- [ ] Step 1: написать падающий unit-тест (кейсы: фед.города с `г. `, `обл.``область`, многосегментный pipe, переворот Удмуртии, Кузбасс-алиас, безнадёжные→null, чистое каноничное имя).
- [ ] Step 2: запустить pest → RED (метод не существует).
- [ ] Step 3: реализовать `lastSegment` (private), `ALIASES` (const), `canonicalRegionName(string): ?string`, `resolveSubjectCode(string): ?int`.
- [ ] Step 4: pest → GREEN.
- [ ] Step 5: commit.
## Task 2: wire команды импорта + `region_normalized`
**Files:**
- Modify: `app/app/Console/Commands/PhoneRangesImportCommand.php:103-116`
- Modify: `app/tests/Feature/Console/PhoneRangesImportCommandTest.php`
- Modify: `app/tests/Fixtures/rossvyaz/sample.csv` (добавить грязные строки)
- [ ] Step 1: добавить в fixture строки с реальными форматами (`г. Москва`, `г. Оренбург|Оренбургская обл.`, `г. Ижевск|Республика Удмуртская`, `г.о. Тольятти`).
- [ ] Step 2: расширить command-тест: проверить, что грязные строки маппятся в правильные коды, безнадёжные → NULL, `region_normalized` заполнен.
- [ ] Step 3: pest → RED.
- [ ] Step 4: команда зовёт `RussianRegions::canonicalRegionName` + `nameToCode`, пишет `region_normalized`.
- [ ] Step 5: pest → GREEN (весь файл).
- [ ] Step 6: commit + push + PR.
## После мержа
Владелец запускает на проде через `artisan-run.yml` (mutating, confirm_apply): `phone-ranges:import --dir=<пакет> --force` — перечитывает реестр с новым маппингом. Будущие лиды резолвятся через Россвязь-fallback → меньше пустого «Город».
@@ -1,290 +0,0 @@
# Router-gate dev/prod re-scope — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Разрешить локальную разработку (composer/npm/git/worktree) через контроллера, сохранив блок боевого/опасного и дисциплины.
**Architecture:** Точечно расширить whitelist Bash-гейта (`enforce-router-gate.mjs`) дев-инструментами + разрешить dev-safe git в общем `shell-content-rules.mjs` (`classifyGitCommand`) с «стражем main» для push. Философия default-deny сохраняется; hard-blacklist опасного и дисциплинарные хуки не трогаются.
**Tech Stack:** Node ESM, vitest (`vitest.config.tools.mjs`, root `app`).
**Spec:** `docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md`
**Verify-команда (вся регрессия tools):**
`npx vitest run --root app --config vitest.config.tools.mjs`
Узкий прогон файла: добавить хвост `<имя>.test` (например `enforce-router-gate.test`).
**Bootstrap-нюанс (важно):** до того как Task 3 (git dev-allow) применится, `git commit` ещё
заблокирован самим гейтом. Поэтому коммиты НЕ делаем по ходу — все правки складываем в рабочее
дерево, гоняем тесты, и **один раз** коммитим в конце (Task 5), когда git уже разрешён. Реализация —
в основной копии (worktree пока недоступен; это и есть bootstrap-исключение из спеки).
---
## Задачи
### Task 1: Разрешить `composer` (install/update/require/remove/dump-autoload)
**Files:**
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 59; SAFE_EXACT ~line 124)
- Test: `tools/enforce-router-gate.test.mjs`
- [ ] **Step 1: Write failing tests** — добавить в конец `enforce-router-gate.test.mjs`:
```js
import { matchBashHardBlacklist as mhb2, classifyBashCommand as cbc2 } from './enforce-router-gate.mjs';
describe('composer dev-allow (owner-authorized 2026-06-02)', () => {
it('allows composer install', () => {
expect(mhb2('composer install')).toBe(null);
expect(cbc2('composer install', {}).result).toBe('allow');
});
it('allows composer require / update / dump-autoload', () => {
expect(cbc2('composer require monolog/monolog', {}).result).toBe('allow');
expect(cbc2('composer update', {}).result).toBe('allow');
expect(cbc2('composer dump-autoload', {}).result).toBe('allow');
});
it('still allows composer install with -d working-dir', () => {
expect(cbc2('composer install -d app --no-interaction', {}).result).toBe('allow');
});
});
```
- [ ] **Step 2: Run to verify FAIL**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: FAIL (composer install currently hard-blacklisted → matchBashHardBlacklist truthy, classify 'block').
- [ ] **Step 3: Remove composer from hard-blacklist** — в `tools/enforce-router-gate.mjs` удалить строку:
```js
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
```
- [ ] **Step 4: Add composer to whitelist** — в массив `SAFE_EXACT`, рядом с существующей `/^composer\s+(?:show|outdated)\b/`, добавить:
```js
/^composer\s+(?:install|update|require|remove|dump-autoload|dump)\b/, // dev-allow 2026-06-02
```
- [ ] **Step 5: Run to verify PASS**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: PASS (включая новый describe).
---
### Task 2: Разрешить `npm` (install/ci/run-скрипты)
**Files:**
- Modify: `tools/enforce-router-gate.mjs` (BASH_HARD_BLACKLIST ~line 60; SAFE_EXACT ~line 122)
- Test: `tools/enforce-router-gate.test.mjs`
- [ ] **Step 1: Write failing tests** — добавить describe:
```js
describe('npm dev-allow (owner-authorized 2026-06-02)', () => {
it('allows npm install / i / ci', () => {
expect(mhb2('npm install')).toBe(null);
expect(cbc2('npm install', {}).result).toBe('allow');
expect(cbc2('npm ci', {}).result).toBe('allow');
});
it('allows npm run <script>', () => {
expect(cbc2('npm run build', {}).result).toBe('allow');
});
});
```
- [ ] **Step 2: Run to verify FAIL**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: FAIL (npm install hard-blacklisted).
- [ ] **Step 3: Remove npm from hard-blacklist** — удалить строку:
```js
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
```
- [ ] **Step 4: Add npm to whitelist** — в `SAFE_EXACT`, рядом с существующей `/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/`, добавить:
```js
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 (любой script)
```
- [ ] **Step 5: Run to verify PASS**
Run: `npx vitest run --root app --config vitest.config.tools.mjs enforce-router-gate.test`
Expected: PASS.
---
### Task 3: Разрешить dev-safe git (commit/add/branch/switch/checkout/stash/worktree)
**Files:**
- Modify: `tools/shell-content-rules.mjs` (GIT_CONDITIONAL_SUB ~line 167; classifyGitCommand ~line 215)
- Test: `tools/shell-content-rules.test.mjs`
- [ ] **Step 1: Write failing tests** — добавить в `shell-content-rules.test.mjs`:
```js
import { classifyGitCommand as cgc2 } from './shell-content-rules.mjs';
describe('git dev-allow (owner-authorized 2026-06-02)', () => {
const noApproval = { approvedGitOps: [], now: 0 };
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
for (const c of [
'git commit -m "x"', 'git add .', 'git branch feature-x',
'git switch -c feature-x', 'git checkout -b feature-x',
'git stash push -m wip', 'git worktree add ../wt -b feat origin/main',
]) {
expect(cgc2(c, noApproval).result).toBe('allow');
}
});
it('STILL blocks commit --no-verify and add -f (hard patterns)', () => {
expect(cgc2('git commit --no-verify -m x', noApproval).result).toBe('block');
expect(cgc2('git add -f ignored.txt', noApproval).result).toBe('block');
});
it('keeps merge/rebase/reset conditional (needs approval)', () => {
expect(cgc2('git reset --hard HEAD~1', noApproval).result).toBe('block');
expect(cgc2('git merge feature', noApproval).result).toBe('block');
});
});
```
- [ ] **Step 2: Run to verify FAIL**
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
Expected: FAIL (commit/branch/... currently conditional → block без approval; worktree → default-deny).
- [ ] **Step 3: Add GIT_DEV_SUB + trim GIT_CONDITIONAL_SUB** — в `tools/shell-content-rules.mjs`:
Заменить блок `GIT_CONDITIONAL_SUB`:
```js
const GIT_CONDITIONAL_SUB = new Set([
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
]);
```
на:
```js
// dev-safe (owner-authorized 2026-06-02): allow без approval. GIT_HARD_PATTERNS
// (--no-verify / add -f / -c / force / --output) пре-фильтруют опасное ВЫШЕ.
const GIT_DEV_SUB = new Set([
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
]);
const GIT_CONDITIONAL_SUB = new Set([
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
]);
```
- [ ] **Step 4: Insert dev-allow + push-guard в classifyGitCommand** — после блока `if (sub === 'remote') { … }` (≈line 213) и ПЕРЕД `// 3. conditional → approve check`, вставить:
```js
// dev-safe git (owner-authorized 2026-06-02): hard-patterns уже отсеяли опасное выше.
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
if (sub === 'push') {
if (/\b(?:main|master)\b/.test(norm)) {
return { result: 'block', reason: 'git push в main/master — клик владельца' };
}
return { result: 'allow', reason: 'git push в фичевую ветку' };
}
```
- [ ] **Step 5: Run to verify PASS**
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
Expected: PASS.
---
### Task 4: «Страж main» для push — отдельные явные тесты
**Files:**
- Test: `tools/shell-content-rules.test.mjs` (логика уже добавлена в Task 3 Step 4 — тут только тесты-замок)
- [ ] **Step 1: Write tests**
```js
describe('git push main-guard (owner-authorized 2026-06-02)', () => {
const na = { approvedGitOps: [], now: 0 };
it('allows push to a feature branch', () => {
expect(cgc2('git push origin worktree-lead-region-tails', na).result).toBe('allow');
expect(cgc2('git push', na).result).toBe('allow');
expect(cgc2('git push -u origin feature-x', na).result).toBe('allow');
});
it('blocks push to main/master', () => {
expect(cgc2('git push origin main', na).result).toBe('block');
expect(cgc2('git push origin HEAD:main', na).result).toBe('block');
expect(cgc2('git push origin master', na).result).toBe('block');
});
it('blocks force-push (hard pattern, unchanged)', () => {
expect(cgc2('git push --force origin feature-x', na).result).toBe('block');
expect(cgc2('git push origin feature-x --force-with-lease', na).result).toBe('block');
});
});
```
- [ ] **Step 2: Run to verify PASS** (логика из Task 3 уже на месте)
Run: `npx vitest run --root app --config vitest.config.tools.mjs shell-content-rules.test`
Expected: PASS.
---
### Task 5: Полная регрессия + коммит в фичевую ветку + PR
- [ ] **Step 1: Полная регрессия tools**
Run: `npx vitest run --root app --config vitest.config.tools.mjs`
Expected: всё GREEN (baseline ~1989 + новые). 0 падений.
- [ ] **Step 2: Дымовая проверка живьём** — после правок гейт читается заново; проверить, что
ранее блокированное теперь проходит (а опасное — нет). Прогнать через Bash:
```
composer --version
```
Expected: проходит (раньше любой `composer install` блокировался; `--version` и так был ок — проверка, что не сломали). Затем убедиться, что `git worktree list` (readonly) и `git status` работают.
- [ ] **Step 3: Создать фичевую ветку + worktree (теперь разрешено) и закоммитить**
```bash
git worktree add "../worktree-gate-rescope" -b feat/gate-dev-prod-rescope origin/main
```
(или коммит в основной копии на новой ветке — на усмотрение исполнителя; main НЕ трогать)
```bash
git add tools/enforce-router-gate.mjs tools/shell-content-rules.mjs \
tools/enforce-router-gate.test.mjs tools/shell-content-rules.test.mjs \
docs/superpowers/specs/2026-06-02-router-gate-dev-prod-rescope-design.md \
docs/superpowers/plans/2026-06-02-router-gate-dev-prod-rescope.md
git commit -m "feat(gate): re-scope router-gate — allow local dev (composer/npm/git/worktree), keep prod+discipline blocks"
git push origin feat/gate-dev-prod-rescope
```
- [ ] **Step 4: Открыть PR (клик владельца)** — дать владельцу ссылку из вывода `git push`; слияние в main — его клик.
---
## Self-Review
- **Spec coverage:** composer (Task 1) ✓ / npm (Task 2) ✓ / git dev-subs + worktree (Task 3) ✓ /
push main-guard (Task 4) ✓ / discipline+prod untouched (явно не трогаем в Task 1-4) ✓ /
«main = owner» (push-guard + PR в Task 5) ✓.
- **Placeholders:** нет — весь код приведён дословно.
- **Type/имена:** `GIT_DEV_SUB` / `GIT_CONDITIONAL_SUB` согласованы Task 3↔4; `classifyGitCommand`,
`matchBashHardBlacklist`, `classifyBashCommand` — реальные экспортируемые имена (проверено по коду).
- **Bootstrap:** коммит батчем в Task 5 (git разрешается только после применения Task 3) — учтено.
@@ -1,81 +0,0 @@
# Lead Region Resolution — runbook раскатки на прод
> Фича: определение настоящего региона лида по телефону (DaData → реестр Россвязи →
> tag-fallback) + каскадная маршрутизация по региону. Код реализован и зелёный
> (Сессии 1-6, TDD). Этот runbook — порядок выкатки оператором на `liderra.ru`.
> Spec: `docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md` v0.5.
> Plan: `docs/superpowers/plans/2026-05-29-lead-region-resolution.md`.
## Решение D1 (зафиксировано заказчиком 01.06.2026)
**Вариант В** — внутри каждой ступени каскада при >3 претендентах лид раздаётся
**взвешенным жребием по остатку дневного лимита**: шанс ∝ остатку, но у каждого
кандидата шанс > 0 (вес ≥ 1) — маленькие клиенты не отрезаются. Реализовано в
`LeadRouter::weightedPick` (вес `max(1, snapshot_daily_limit delivered_today)`).
## Предусловия
- `DADATA_API_KEY` + `DADATA_SECRET` — завести в **YC Lockbox** (НЕ в git/.env репозитория).
Прокинуть в окружение прод-воркеров (`DADATA_API_KEY`, `DADATA_SECRET`).
- Feature-flag `LEAD_REGION_RESOLVER_ENABLED` (по умолчанию `false` → текущее tag-поведение).
- Бюджет: `DADATA_DAILY_CAP_RUB` (дефолт 10000), `DADATA_CALL_COST_KOPECKS` (дефолт 60 —
**откалибровать по фактическому тарифу DaData** после первого дня).
## Порядок выкатки
1. **Миграция БД.** Накатить `2026_05_31_100000_create_phone_ranges_and_resolution_log`
(создаёт `phone_ranges`, `phone_ranges_imports`, `lead_region_resolution_log` +
колонки в `supplier_leads`/`deals`). Партиции журнала на старте — m05/m06; далее
их подхватывает `partitions:create-months` (уже зарегистрирован в `MonthlyPartitionManager`).
- На проде миграция делает `SET ROLE crm_migrator` (паттерн проекта).
2. **Импорт реестра Россвязи.** Скачать пакет выписок с
`rossvyaz.gov.ru/deyatelnost/resurs-numeracii/...` (~500-600 файлов) в каталог,
затем `php artisan phone-ranges:import --dir=<каталог>`.
- **NB парсер:** ожидает CSV `;`-разделитель, колонки `АВС/ DEF;От;До;Емкость;Оператор;Регион`.
Реальные файлы Россвязи могут быть в cp1251 / иметь другие заголовки — сверить на
первом импорте; при расхождении поправить `resolveColumns()` (это и есть первая
боевая валидация — автотест покрывает CSV-фикстуру, не реальный формат).
- **NB swap:** atomic RENAME (`phone_ranges``_old`, staging → `phone_ranges`) НЕ
покрыт автотестом (коммитящий RENAME сломал бы общую тестовую БД). **Этот импорт —
первая боевая проверка свапа.** Сначала прогнать `--dry-run` (staging без свапа),
проверить `phone_ranges_staging` глазами, потом без `--dry-run`. Откат:
`phone-ranges:rollback` (см. spec §6.4 — команда отката пока не реализована,
при необходимости — ручной RENAME `phone_ranges_old` обратно).
3. **Деплой кода с `LEAD_REGION_RESOLVER_ENABLED=false`.** Резолвер выключен →
поведение идентично текущему (tag-fallback). Каскад работает (но без точного
региона, т.к. `resolved_subject_code=null` → шаг 2 «вся РФ» как раньше).
4. **Smoke на staging/проде:** `php artisan phone-region:smoke --phone=79161234567`
(с реальным ключом — платный вызов, в БД не пишет). Проверить, что DaData отвечает,
регион/оператор резолвятся, Россвязь-fallback находит префиксы. Прогнать §9.4 — ~100
реальных prod-номеров, сверить распределение источников.
5. **Включить флаг (сразу 100%):** `LEAD_REGION_RESOLVER_ENABLED=true`. Рубильник
глобальный — резолвер включается сразу для **всего** потока лидов. **Долевую
(постепенную) раскатку НЕ делаем** (решение заказчика 01.06.2026): никакого
`hash(phone) % 100`-гейта не вводим, фича идёт на 100% с первого включения.
6. **Мониторинг 1 день:** `lead_region_resolution_log` — распределение `region_source`
(ожидание: dadata большинство, tag < 20%, unknown < 5% — spec §8.2). Проверить
`DADATA_DAILY_CAP_RUB` не упирается. Откалибровать `DADATA_CALL_COST_KOPECKS`.
7. **Штатный режим:** фича уже работает на 100% потока (с шага 5) — долевого гейта нет,
убирать нечего. Единственный рычаг управления — флаг `LEAD_REGION_RESOLVER_ENABLED`.
8. **Ежемесячный cron** импорта реестра (`phone-ranges:import`, 4-е число 03:00 МСК —
spec §6.3) — добавить в планировщик/`artisan-run`.
## Откат
- Мгновенный: `LEAD_REGION_RESOLVER_ENABLED=false` → резолвер возвращает tag-fallback,
каскад ведёт себя как до фичи. Код деплоить заново не нужно.
- Реестр: `phone_ranges_old` хранит предыдущую версию (ручной RENAME при проблеме импорта).
## Что отложено (followups, не блокируют ядро)
- **Метрики §8.1** (`phone_resolution.source.*` и т.д.) — в проекте нет механизма
Prometheus/StatsD; отложено до его появления.
- **Долевая (постепенная) раскатка****НЕ делаем** (решение заказчика 01.06.2026):
фича включается сразу на 100%, `hash(phone)%100`-гейт не вводится.
- **`phone-ranges:rollback`** — команда отката свапа (spec §6.4) не реализована.
- **`deals.region_source`** — не добавлялась (по спеке регион-источник живёт на
`supplier_leads` + в журнале). CSV-merge (§3.12) обновляет регион сделки по
эвристике «webhook dadata/rossvyaz > CSV-tag», без хранения source на сделке.
- **pg_anonymizer-маски (§7.2)** на `lead_region_resolution_log` — при настройке масок дампов.
- **152-ФЗ:** телефон в журнале маскирован (`7XXX***YYYY`), `dadata_response_masked`
без сырого номера — базовое покрытие есть; полный аудит ПДн — через `pdn-152fz-audit`.
@@ -1,131 +0,0 @@
# Router-gate re-scope: «боевое блокируем, локальную разработку разрешаем»
**Дата:** 2026-06-02
**Статус:** design (утверждён владельцем; реализация — отдельным планом)
**Автор контекста:** сессия lead-region-tails
## Проблема
Router-gate v4 (`tools/enforce-router-gate.mjs`) работает в режиме «по умолчанию запрещено»
(whitelist для Bash + hard-blacklist + MCP-классификатор + дисциплинарные хуки). Он задумывался
как защита **боевого** контура (выкат на liderra.ru, изменение боевой БД, секреты, запуск
воркфлоу), но по факту блокирует и **весь локальный инструмент разработки**: `composer install`,
`npm install`, `git worktree`, `git commit`/`push`, и даже правку тест-файлов (через
`enforce-tdd-real-test-verifier`). Это делает обычную разработку через контроллера непрактичной —
любая PHP/JS-задача с тестами упирается в стену (подтверждено в сессии 2026-06-02: попытка сделать
fix реестра Россвязи провалилась на цепочке взаимно-охраняющих замков).
## Цель
Перенастроить замок так, чтобы он блокировал **только боевое и опасное**, а **локальную
разработку разрешал** — сохранив при этом дисциплину работы контроллера и защиту боевого контура.
## Решения (утверждены владельцем 2026-06-02)
1. **Дисциплину оставляем.** Хуки качества (TDD-gate, tdd-real-test-verifier, chain-recommendation,
graph-first, override-limit, llm-judge, coverage-verify, memory-coverage и пр.) — **не трогаем**.
Контроллер продолжает писать тесты до кода и не срезать углы.
2. **Защиту боевого оставляем железно.** Выкат/боевая БД/секреты/запуск воркфлоу/защищённые
пути — без изменений.
3. **Инструменты разработки разрешаем.** composer/npm/pest/git/worktree.
4. **Граница git:** ветки — контроллер сам (commit/push в не-главную ветку + подготовка PR);
слияние в main, push в main, force-push, выкат — **клик владельца**.
## Подход
**Approach A (выбран):** точечно расширить whitelist дев-инструментами, сохранив философию
«по умолчанию запрещено». Правим **два файла**`tools/enforce-router-gate.mjs` (composer/npm) и
`tools/shell-content-rules.mjs` (git; там общий `classifyGitCommand`). MCP-классификатор
(`tools/mcp-tool-classifier.mjs`) и дисциплинарные хуки — без изменений.
Отвергнут **Approach B** (перевернуть в default-allow + blacklist опасного): любой пропуск в
перечне опасного = дыра; ломает безопасную философию default-deny.
## Матрица: что блокируем / что разрешаем
### Остаётся ЗАБЛОКИРОВАННЫМ
| Категория | Примеры | Где |
|---|---|---|
| Боевой контур | выкат на сайт, изменение боевой БД, секреты/`.env`, защищённые пути (CLAUDE.md, memory/, transcripts, `~/.claude/runtime`) | без изменений |
| GitHub на запись | `create_*`/`update_*`/`merge_*`/`push_files`/`actions_run_trigger` | MCP-классификатор без изменений (read-only, открытый 2026-06-02, остаётся) |
| Опасные команды | `rm`/`mv`/`cp`/`chmod`/`chown`, `curl -X POST/PUT/DELETE`, `wget`, `nc`/`ncat`/`socat`, `node -e` с `fs.*`, `eval`, `bash -c`/`sh -c`, `python -c`, redirects в protected | hard-blacklist без изменений |
| Дисциплина | TDD-gate, tdd-real-test-verifier, override-limit, chain-recommendation, graph-first, llm-judge, coverage | хуки без изменений |
| Главная ветка | `git push` в main, `git push --force`, слияние в main | новый «страж main» |
### Становится РАЗРЕШЁННЫМ (локальная разработка)
| Инструмент | Команды |
|---|---|
| Composer | `composer install`, `composer dump-autoload`, `composer require`, `composer update` |
| NPM | `npm install`, `npm ci`, `npm run <script>` |
| Тесты | `pest`, `vendor/bin/pest`, `php artisan test` (уже частично в whitelist) |
| Git (ветки) | `git commit`, `git add`, `git branch`, `git switch`/`checkout`, `git worktree`, `git stash`, `git push` **в не-главную ветку** |
## Изменения в коде (два файла)
Git-логика живёт не в самом router-gate, а в общем модуле `shell-content-rules.mjs`
(`classifyGitCommand`, используется и Bash-, и PowerShell-гейтом). Поэтому правок — два файла.
### `tools/enforce-router-gate.mjs` (composer / npm)
1. **Из hard-blacklist (`BASH_HARD_BLACKLIST`) убрать** строки про `composer install/update/require/remove`
и `npm install/i/update/remove/uninstall`. `yarn`/`pnpm` остаются заблокированными (проект на npm,
не нужны). Истинно-опасные fs/сеть/exec (`rm/mv/cp/chmod`, `curl POST`, `wget`, `nc`, `node -e fs`,
`eval`, `bash -c`, `python -c`, redirects) — **без изменений**.
2. **В whitelist (`SAFE_EXACT`) добавить:** `composer (install|update|require|remove|dump-autoload|dump)`,
`npm (install|i|ci)`, `npm run <script>` (любой скрипт). Существующие `composer show/outdated/test/...`
и `npm test/run test/run lint` — остаются.
### `tools/shell-content-rules.mjs` (git)
1. **Новый `GIT_DEV_SUB`** = `{add, commit, branch, switch, checkout, stash, worktree}` → в
`classifyGitCommand` после hard-pattern-проверки возвращать `allow`. Эти подкоманды **убрать** из
`GIT_CONDITIONAL_SUB`. (`worktree` сейчас падает в default-deny — попадёт в dev-allow.)
2. **`GIT_HARD_PATTERNS` не трогаем** — `--no-verify`, `git add -f`, `git -c`, force-push, `--output`/`-o`
и т.п. по-прежнему блокируются ПЕРВЫМИ, до dev-allow. То есть `git commit --no-verify` и `git add -f`
остаются заблокированы даже как «dev».
3. **Страж main для `push`** (`mainPushGuard`, чистая функция): `push` остаётся, но —
если в аргументах фигурирует `main`/`master` как ref (`git push origin main`, `HEAD:main`, `:main`)
**block** (клик владельца); force-push уже заблокирован `GIT_HARD_PATTERNS`. Иначе (`git push origin <feature>`,
bare `git push`) → allow. Допущение: bare `git push` считаем пушем не-главной ветки (контроллер по модели
всегда на не-главной ветке); пуш в main возможен только явным `origin main` → пойман.
4. **Conditional остаётся** для `merge, rebase, reset, cherry-pick, revert, pull, clean` (require approval) —
риск потери работы / слияние в main = клик владельца.
**Не меняем:** `tools/mcp-tool-classifier.mjs`, `tools/bash-tokenizer.mjs` (`isMutatingSegment` — чейн-правило
C13 «цепочка с мутацией → блок» сохраняется), любые `enforce-*` дисциплинарные хуки, `.claude/settings.json`.
## Тестирование (TDD)
Через `tools/enforce-router-gate.test.mjs` (vitest, работает в основной копии):
- `composer install` / `composer require x` → allow; `composer` (без подкоманды) → как раньше.
- `npm install` → allow; `npm run build` → allow.
- `git commit -m x` / `git worktree add ...` / `git push origin feature-x` → allow.
- `git push origin main` / `git push --force`**block** (страж main).
- Регресс: опасное по-прежнему блокируется — `rm -rf x`, `curl -X POST`, `node -e "...fs..."`,
`eval`, `python -c` → block.
- Полная регрессия tools-тестов (`npx vitest run --root app --config vitest.config.tools.mjs`).
## Граница реализации (bootstrap-нюанс)
Сам этот re-scope — bootstrap-исключение: его нельзя делать в worktree (worktree пока заблокирован).
Реализуется в основной копии (там активен живой замок и работает vitest). После правки замка
`git`/`worktree`/`composer` становятся разрешены — дальнейшие задачи (например, fix реестра)
пойдут уже по модели «ветка + PR».
## Остаточные риски (приняты)
- Разрешён `composer require`/`npm install` → теоретический supply-chain (установка пакета).
Принято: это собственный проект владельца; дисциплина и code-review остаются.
- `rm`/`mv`/`cp` остаются заблокированы — если реально мешают разработке, пересматриваем отдельно
(файловые правки покрываются инструментами Write/Edit).
- «Страж main» опирается на парсинг аргументов `git push`; экзотические формы (push по URL,
refspec-трюки) при сомнении → block (fail-safe в сторону защиты main).
## Что НЕ входит (YAGNI)
- Не инвертируем модель замка (default-deny остаётся).
- Не трогаем боевые воркфлоу, секреты, MCP-write.
- Не ослабляем дисциплину.
+18 -5
View File
@@ -26,6 +26,7 @@ import {
lastAssistantText,
parseCoverageLine,
turnToolUses,
sessionToolUses,
findOverride,
logOverride,
exitDecision,
@@ -38,7 +39,7 @@ const MUTATING_TOOLS = new Set([
]);
export function decide({
toolUses, assistantText, override,
toolUses, assistantText, override, priorSkillNames = [],
}) {
// Pure conversational turn — skip.
const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name));
@@ -59,12 +60,19 @@ export function decide({
}
if (cov.channel === 'skill') {
const found = toolUses.some((u) => u.name === 'Skill' && u.input && (u.input.skill === cov.id || u.input.skill === cov.id.replace(/^superpowers:/, '')));
if (!found) {
// Accept if the skill was invoked in THIS turn OR anywhere earlier in this
// session (item G): a skill chosen in a prior turn stays active, so an honest
// skill:X line on a continuation turn must not be punished into under-reporting.
// Still unforgeable — a real Skill tool_use must exist in the transcript.
const norm = (s) => String(s || '').replace(/^superpowers:/, '');
const idNorm = norm(cov.id);
const foundThisTurn = toolUses.some((u) => u.name === 'Skill' && u.input && norm(u.input.skill) === idNorm);
const foundPrior = (priorSkillNames || []).some((n) => norm(n) === idNorm);
if (!foundThisTurn && !foundPrior) {
return {
block: true,
message: [
`[enforce-coverage-verify] coverage says skill:${cov.id} but the Skill tool was never invoked with that name in this turn.`,
`[enforce-coverage-verify] coverage says skill:${cov.id} but the Skill tool was never invoked with that name in this turn or any prior turn of this session.`,
`Either invoke the skill via Skill tool, or switch coverage to direct:<role> with justification.`,
].join('\n'),
};
@@ -87,8 +95,13 @@ async function main() {
const toolUses = turnToolUses(transcript);
const assistantText = lastAssistantText(transcript);
// Session-wide Skill invocations (item G): a skill chosen in a prior turn is
// still active and may legitimately be named in this turn's coverage line.
const priorSkillNames = sessionToolUses(transcript)
.filter((u) => u.name === 'Skill' && u.input && u.input.skill)
.map((u) => u.input.skill);
const result = decide({ toolUses, assistantText, override });
const result = decide({ toolUses, assistantText, override, priorSkillNames });
exitDecision(result);
} catch {
exitDecision({ block: false });
+34
View File
@@ -1,6 +1,40 @@
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-coverage-verify.mjs';
// Cross-turn skill credit (backlog item G, 2026-05-31): a skill chosen in a PRIOR
// turn stays active; an honest `skill:X` line on a continuation turn must NOT be
// blocked just because the Skill tool was not re-invoked this turn. decide() takes
// priorSkillNames (real Skill tool_uses from earlier in the session transcript).
describe('enforce-coverage-verify / decide — cross-turn active skill (enforce-coverage-verify.mjs)', () => {
it('credits skill:X when X was invoked in a PRIOR turn (priorSkillNames)', () => {
const r = decide({
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
assistantText: 'coverage: skill:superpowers:test-driven-development\nработаю',
priorSkillNames: ['superpowers:test-driven-development'],
});
expect(r.block).toBe(false);
});
it('normalizes the superpowers: prefix for prior-turn skills too', () => {
const r = decide({
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
assistantText: 'coverage: skill:superpowers:test-driven-development',
priorSkillNames: ['test-driven-development'],
});
expect(r.block).toBe(false);
});
it('still blocks skill:X when X is neither in this turn nor any prior turn', () => {
const r = decide({
toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }],
assistantText: 'coverage: skill:superpowers:test-driven-development',
priorSkillNames: ['some-other-skill'],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/never invoked/);
});
});
describe('enforce-coverage-verify / decide', () => {
it('allows turn with no mutating tools (pure conversational)', () => {
const r = decide({ toolUses: [{ name: 'Read', input: {} }], assistantText: 'just talking' });
+121 -4
View File
@@ -11,10 +11,12 @@
* Activation: settings.json registration is deferred to Phase H-α/H-β
* batch step. main() is a no-op (exit 0) until then.
*/
import { acquire, release, computeWorkspaceHash } from './parallel-session-lock.mjs';
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { acquire, release, computeWorkspaceHash, isStale } from './parallel-session-lock.mjs';
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs';
import { classifyBashCommand } from './enforce-router-gate.mjs';
/**
* Pure decision: given an acquire() result, decide block/allow.
@@ -29,12 +31,41 @@ export function decide({ acquireResult, sessionId }) {
if (!acquireResult || typeof acquireResult !== 'object') return { block: false };
if (acquireResult.acquired) return { block: false };
const holder = acquireResult.holder || {};
// Identify the holder by its STABLE session id, not the pid: the recorded pid
// is the transient hook-node pid and changes between attempts, so chasing it
// leads to closing the wrong session. Surface the pid only as a triage hint.
return {
block: true,
reason: `parallel session lock held by ${holder.session_id || 'unknown'} (pid ${holder.pid || '?'}) — wait or close that session first`,
reason: `parallel session lock held by session ${holder.session_id || 'unknown'} (current pid ${holder.pid || '?'}, may change between attempts — identify the session by its id, not pid) — wait for the 5-min TTL or close THAT session`,
};
}
/**
* Calibration (2026-05-31, SCOPE fix, NOT a discipline drop). The lock's purpose
* is to serialize concurrent FILE MUTATION between sessions on the same worktree.
* A readonly Bash command (git status/log/diff, cat, grep, ls "смотрелки")
* mutates nothing, so a peer session's lock must NOT block it. Reuse the
* router-gate Bash classifier: an allow-verdict whose reason mentions
* readonly/reading is a no-state-change command. Mirrors the LLM-judge readonly
* calibration. Everything that can mutate file edits, git commit/push,
* dangerous Bash, and every NON-Bash tool still acquires/checks the lock, so
* same-worktree mutation serialization is unchanged.
*
* @param {object} event
* @returns {boolean}
*/
export function isReadonlyBashEvent(event) {
if (!event || event.tool_name !== 'Bash') return false;
const command = (event.tool_input && event.tool_input.command) || '';
if (!command) return false;
try {
const c = classifyBashCommand(command, {});
return !!c && c.result === 'allow' && /readonly|reading/i.test(c.reason || '');
} catch {
return false;
}
}
/**
* PreToolUse wiring: acquire (or same-session refresh / stale takeover) the lock,
* then decide block/allow. I/O injected for testability.
@@ -60,6 +91,64 @@ export function runReleaseAction({ event, cwd, readLock, deleteLock }) {
return { released: true };
}
/**
* Resolve the stable work-tree root used as the lock key. Keys on the SESSION's
* cwd (`event.cwd`, stable across resume) resolved to the git work-tree root
* NOT the hook's `process.cwd()`, which collapses to the main repo dir after a
* session resume and thereby false-blocks sessions in DIFFERENT worktrees.
* Pure (I/O injected): `runGitToplevel(dir)` returns the toplevel or '' on failure.
*
* @param {object} p
* @param {object} p.event
* @param {string} p.processCwd
* @param {(dir:string)=>string} p.runGitToplevel
* @returns {string}
*/
export function resolveWorkspacePath({ event, processCwd, runGitToplevel }) {
const dir = (event && typeof event.cwd === 'string' && event.cwd) ? event.cwd : processCwd;
try {
const top = runGitToplevel(dir);
if (top && typeof top === 'string') return top;
} catch { /* fall through to raw dir (fail-open) */ }
return dir;
}
/**
* Disk hygiene: delete leaked lock files whose record is ALREADY stale by the
* shared isStale() definition (so an active within-TTL lock is never touched).
* Pure (I/O injected). Best-effort: a failed read counts the file as stale
* (garbage), a failed delete is swallowed hygiene must never break the gate.
*
* @param {object} p
* @param {string[]} p.files - absolute lock-file paths
* @param {(f:string)=>object|null} p.readRecord
* @param {(f:string)=>void} p.deleteRecord
* @param {(rec:object|null, now:number)=>boolean} p.isStaleFn
* @param {number} p.now
* @returns {{pruned: number}}
*/
export function pruneStaleLocks({ files, readRecord, deleteRecord, isStaleFn, now }) {
let pruned = 0;
for (const f of files || []) {
let rec = null;
try { rec = readRecord(f); } catch { rec = null; }
if (isStaleFn(rec, now)) {
try { deleteRecord(f); pruned++; } catch { /* best-effort */ }
}
}
return { pruned };
}
function realGitToplevel(dir) {
try {
return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
} catch { return ''; }
}
function lockPathFor(cwd) {
return join(runtimeDir(), `session-lock-${computeWorkspaceHash(cwd)}.json`);
}
@@ -82,7 +171,10 @@ async function main() {
// a lock bug can NEVER wedge the user out of their own session.
try {
const event = parseEventJson(await readStdin());
const cwd = process.cwd();
// Key by the session's stable work-tree root (event.cwd → git toplevel),
// not the volatile hook process.cwd() (collapses to main on resume → false
// cross-worktree blocks). Fallback to process.cwd() keeps prior behavior.
const cwd = resolveWorkspacePath({ event, processCwd: process.cwd(), runGitToplevel: realGitToplevel });
const p = lockPathFor(cwd);
// Stop event carries no tool_name → release path.
@@ -91,6 +183,31 @@ async function main() {
return exitDecision({ block: false });
}
// Calibration (2026-05-31): a readonly Bash command never mutates the
// worktree, so it is outside the lock's mutation-serialization scope — allow
// without acquiring/blocking. Mutating tools (and every non-Bash tool) fall
// through to acquire/check below, so serialization is unchanged.
if (isReadonlyBashEvent(event)) {
return exitDecision({ block: false });
}
// Best-effort disk hygiene (B): drop leaked stale lock files before acquiring.
// isStale-gated → an active within-TTL lock is never pruned, so same-worktree
// serialization is untouched. Wrapped so hygiene can never break the gate.
try {
const dir = runtimeDir();
const files = readdirSync(dir)
.filter((f) => /^session-lock-.*\.json$/.test(f))
.map((f) => join(dir, f));
pruneStaleLocks({
files,
readRecord: (fp) => realReadLock(fp),
deleteRecord: (fp) => realDeleteLock(fp),
isStaleFn: isStale,
now: Date.now(),
});
} catch { /* hygiene is best-effort */ }
// PreToolUse on a mutating tool → acquire/refresh, then block/allow.
const r = runAcquireDecision({
event,
+164 -1
View File
@@ -1,7 +1,7 @@
// tools/enforce-parallel-session-lock.test.mjs
// Stream H Task 7 — wrapper tests around the pure parallel-session-lock module.
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-parallel-session-lock.mjs';
import { decide, isReadonlyBashEvent } from './enforce-parallel-session-lock.mjs';
describe('enforce-parallel-session-lock wrapper (Stream H Task 7)', () => {
it('allow when acquire succeeded (fresh own-lock)', () => {
@@ -43,6 +43,25 @@ describe('enforce-parallel-session-lock wrapper (Stream H Task 7)', () => {
});
});
// D (2026-05-31): the block message must steer the human to the STABLE identity
// (session id), not the transient hook pid — chasing the pid was what caused the
// owner to close the wrong session and deadlock the workspace.
describe('decide() message clarity (D) — pid is transient, identify by session id', () => {
const blocked = { acquired: false, holder: { session_id: 'sess-A', pid: 12552, acquired_at: 0 } };
it('names the holder session id as the stable identity', () => {
expect(decide({ acquireResult: blocked, sessionId: 's1' }).reason).toMatch(/sess-A/);
});
it('marks the pid as changeable so the human does not chase it', () => {
expect(decide({ acquireResult: blocked, sessionId: 's1' }).reason).toMatch(/may change|transient/i);
});
it('still surfaces the pid for triage', () => {
expect(decide({ acquireResult: blocked, sessionId: 's1' }).reason).toMatch(/12552/);
});
});
// Live wiring (point 2, 2026-05-31): PreToolUse acquires/refreshes the lock,
// Stop releases it. I/O is injected (readLock/writeLock/deleteLock) so the
// wiring stays pure and unit-testable; main() binds real fs.
@@ -131,3 +150,147 @@ describe('runReleaseAction — Stop release wiring', () => {
expect(deleted).toBe(false);
});
});
// Cross-worktree false-block fix (2026-05-31). The lock must key on the session's
// stable work-tree root (from event.cwd → git toplevel), NOT the hook process.cwd()
// — which collapses to the main repo dir after a session resume, making sessions in
// DIFFERENT worktrees share one lock and block each other.
import { resolveWorkspacePath, pruneStaleLocks } from './enforce-parallel-session-lock.mjs';
describe('resolveWorkspacePath — stable worktree key', () => {
it('keys on event.cwd (the session worktree), not the hook process.cwd()', () => {
const r = resolveWorkspacePath({
event: { cwd: '/repo/.claude/worktrees/wt-A' },
processCwd: '/repo',
runGitToplevel: (dir) => dir,
});
expect(r).toBe('/repo/.claude/worktrees/wt-A');
});
it('gives different keys for two different worktrees (no cross-block)', () => {
const opts = { processCwd: '/repo', runGitToplevel: (dir) => dir };
const a = resolveWorkspacePath({ event: { cwd: '/repo/.claude/worktrees/wt-A' }, ...opts });
const b = resolveWorkspacePath({ event: { cwd: '/repo/.claude/worktrees/wt-B' }, ...opts });
expect(a).not.toBe(b);
});
it('resolves to the git work-tree root (collapses subdir variance)', () => {
const r = resolveWorkspacePath({
event: { cwd: '/repo/.claude/worktrees/wt-A/tools' },
processCwd: '/repo',
runGitToplevel: () => '/repo/.claude/worktrees/wt-A',
});
expect(r).toBe('/repo/.claude/worktrees/wt-A');
});
it('falls back to processCwd when event.cwd is absent', () => {
const r = resolveWorkspacePath({
event: { tool_name: 'Edit' },
processCwd: '/repo',
runGitToplevel: (dir) => dir,
});
expect(r).toBe('/repo');
});
it('falls back to the raw dir when git toplevel resolution fails (fail-open)', () => {
const r = resolveWorkspacePath({
event: { cwd: '/some/dir' },
processCwd: '/repo',
runGitToplevel: () => '',
});
expect(r).toBe('/some/dir');
});
});
// B (2026-05-31): disk hygiene. Leaked lock files (session closed without a clean
// Stop) pile up in ~/.claude/runtime. Pruning ONLY removes records that are
// already stale by the SAME isStale() definition acquire() uses — so it can never
// drop an active (within-TTL) lock and never weakens same-worktree serialization.
describe('pruneStaleLocks — drops only already-stale leaked locks (B)', () => {
const fresh = { schema_version: 1, session_id: 'A', pid: 1, acquired_at: 1000, ttl_ms: 300000 };
const stale = { schema_version: 1, session_id: 'B', pid: 2, acquired_at: 0, ttl_ms: 100 };
const isStaleFn = (rec, now) => !rec || (now - (rec && rec.acquired_at || 0)) > ((rec && rec.ttl_ms) || 300000);
it('deletes stale lock files and never the fresh (active) ones', () => {
const records = { '/r/lock-fresh.json': fresh, '/r/lock-stale.json': stale };
const deleted = [];
const r = pruneStaleLocks({
files: Object.keys(records),
readRecord: (f) => records[f],
deleteRecord: (f) => deleted.push(f),
isStaleFn, now: 1000,
});
expect(deleted).toEqual(['/r/lock-stale.json']);
expect(r.pruned).toBe(1);
});
it('treats an unreadable/garbage lock file as stale and prunes it', () => {
const deleted = [];
pruneStaleLocks({
files: ['/r/garbage.json'],
readRecord: () => { throw new Error('bad json'); },
deleteRecord: (f) => deleted.push(f),
isStaleFn, now: 1000,
});
expect(deleted).toEqual(['/r/garbage.json']);
});
it('never throws when a delete fails (best-effort hygiene)', () => {
expect(() => pruneStaleLocks({
files: ['/r/x.json'],
readRecord: () => stale,
deleteRecord: () => { throw new Error('locked'); },
isStaleFn, now: 1000,
})).not.toThrow();
});
it('does nothing for an empty file list', () => {
const r = pruneStaleLocks({ files: [], readRecord: () => null, deleteRecord: () => {}, isStaleFn, now: 1 });
expect(r.pruned).toBe(0);
});
});
// ── Calibration (2026-05-31): readonly Bash is outside the lock scope ──
// The lock serializes concurrent FILE MUTATION between sessions on the same
// worktree. A readonly Bash command (git status/log/diff, cat, grep, ls)
// mutates nothing, so a peer session's lock must NOT block it. This mirrors the
// LLM-judge readonly calibration (isReadonlyBashEvent in enforce-llm-judge-per-tool).
// Everything that can mutate — file edits, git commit/push, dangerous Bash, and
// every NON-Bash tool — still acquires/checks the lock, so mutation
// serialization is unchanged (scope fix, NOT a discipline drop).
describe('isReadonlyBashEvent — readonly Bash bypasses the lock (calibration 2026-05-31)', () => {
const ev = (command) => ({ tool_name: 'Bash', tool_input: { command } });
it('treats readonly git (status/log/diff) as readonly', () => {
expect(isReadonlyBashEvent(ev('git status'))).toBe(true);
expect(isReadonlyBashEvent(ev('git log --oneline -5'))).toBe(true);
expect(isReadonlyBashEvent(ev('git diff'))).toBe(true);
});
it('treats whitelisted reading commands (cat/grep/ls) as readonly', () => {
expect(isReadonlyBashEvent(ev('ls -la'))).toBe(true);
expect(isReadonlyBashEvent(ev('cat README.md'))).toBe(true);
expect(isReadonlyBashEvent(ev('grep -n foo bar.txt'))).toBe(true);
});
it('does NOT treat mutating Bash as readonly (still acquires/blocks)', () => {
expect(isReadonlyBashEvent(ev('rm -rf x'))).toBe(false);
expect(isReadonlyBashEvent(ev('git commit -m "x"'))).toBe(false);
expect(isReadonlyBashEvent(ev('npm install foo'))).toBe(false);
});
it('does NOT treat a chain with a mutating part as readonly (C13)', () => {
expect(isReadonlyBashEvent(ev('git status && rm x'))).toBe(false);
});
it('only applies to the Bash tool — other tools still acquire the lock', () => {
expect(isReadonlyBashEvent({ tool_name: 'Edit', tool_input: { file_path: 'a.js' } })).toBe(false);
expect(isReadonlyBashEvent({ tool_name: 'Write', tool_input: { file_path: 'a.js' } })).toBe(false);
});
it('is safe on malformed input', () => {
expect(isReadonlyBashEvent(null)).toBe(false);
expect(isReadonlyBashEvent({ tool_name: 'Bash', tool_input: {} })).toBe(false);
expect(isReadonlyBashEvent({ tool_name: 'Bash' })).toBe(false);
});
});
+2 -2
View File
@@ -72,8 +72,8 @@ describe('classifyPowerShellCommand', () => {
it('blocks reading a protected path', () => {
expect(classifyPowerShellCommand('Get-Content ~/.claude/settings.json', {}).result).toBe('block');
});
it('routes git through shared classifier (commit dev-allowed 2026-06-02 re-scope)', () => {
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
it('routes git through shared classifier (block unapproved commit)', () => {
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
});
it('allows readonly git through PowerShell', () => {
expect(classifyPowerShellCommand('git status', {}).result).toBe('allow');
+28 -2
View File
@@ -21,13 +21,15 @@ import {
parseEventJson,
readRouterState,
readRationalizationFlags,
readTranscript,
sessionToolUses,
findOverride,
loadOverrideVocab,
} from './enforce-hook-helpers.mjs';
const SUPPRESS_RULE = 'classifier-mismatch';
export function buildReminder({ classification, recentFlags, override }) {
export function buildReminder({ classification, recentFlags, override, activeSkills = [] }) {
const lines = ['## §17 Coverage / Discipline Reminder', ''];
if (override) {
lines.push(`Override phrase detected: "${override.phrase}". The following rules are suppressed for THIS prompt only:`);
@@ -38,6 +40,16 @@ export function buildReminder({ classification, recentFlags, override }) {
lines.push(' `coverage: <channel>:<id>`');
lines.push('Channels: skill, node, chain, hook, agent, direct.');
lines.push('');
// Item G (2026-05-31): a skill invoked in an EARLIER turn stays active. Remind
// explicitly so the coverage line is not under-reported as direct/chain when the
// work actually continues under that skill. (The verifier now accepts a prior-turn
// skill, so this report is honest, not a violation.)
if (Array.isArray(activeSkills) && activeSkills.length > 0) {
lines.push('**Active skill(s) still in effect from earlier this session:**');
for (const s of activeSkills) lines.push(` - ${s}`);
lines.push('If your work continues under one of these, report `coverage: skill:<name>` (not direct/chain).');
lines.push('');
}
if (classification) {
lines.push(`**Classifier output:** task_type=${classification.task_type || 'unknown'}, confidence=${classification.confidence ?? 'n/a'}`);
if (classification.recommended_node) {
@@ -94,7 +106,21 @@ async function main() {
const flags = readRationalizationFlags(sessionId);
const reminder = buildReminder({ classification, recentFlags: flags, override });
// Item G: detect skills invoked earlier this session (still active). The
// transcript at UserPromptSubmit holds all prior turns. Best-effort.
let activeSkills = [];
try {
const transcript = readTranscript(event.transcript_path);
const seen = new Set();
for (const u of sessionToolUses(transcript)) {
if (u.name === 'Skill' && u.input && u.input.skill && !seen.has(u.input.skill)) {
seen.add(u.input.skill);
activeSkills.push(u.input.skill);
}
}
} catch { activeSkills = []; }
const reminder = buildReminder({ classification, recentFlags: flags, override, activeSkills });
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
+16
View File
@@ -66,6 +66,22 @@ describe('enforce-prompt-injection / buildReminder', () => {
expect(txt).toMatch(/verify-before-push/);
});
it('reminds about active skills carried over from prior turns (item G)', () => {
const txt = buildReminder({
classification: null,
recentFlags: [],
activeSkills: ['superpowers:test-driven-development'],
});
expect(txt).toMatch(/Active skill/i);
expect(txt).toMatch(/test-driven-development/);
expect(txt).toMatch(/coverage: skill:/);
});
it('omits the active-skill note when none are active', () => {
const txt = buildReminder({ classification: null, recentFlags: [], activeSkills: [] });
expect(txt).not.toMatch(/Active skill/i);
});
it('does NOT advertise dead override-vocabulary phrases (v4 stub — 1A 2026-05-31)', () => {
const txt = buildReminder({ classification: null, recentFlags: [] });
// findOverride/loadOverrideVocab — заглушки (vocab removed in v4); реклама фраз
+9 -11
View File
@@ -56,8 +56,8 @@ export const BASH_HARD_BLACKLIST = [
{ re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' },
{ re: /\b(?:bash|sh)\s+-c\b/, reason: 'bash/sh -c запрещён' },
{ re: /(^|\s|;|&&|\|\|)eval\b/, reason: 'eval запрещён' },
// composer/npm перенесены в whitelist (dev-allow, 2026-06-02 re-scope) — это локальные
// инструменты разработки, не боевой контур. yarn/pnpm остаются заблокированы (проект на npm).
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
{ re: /\b(?:yarn|pnpm)\s+(?:add|install|remove)\b/, reason: 'yarn/pnpm add/install/remove запрещён' },
{ re: /\bnpx\s+claude-/, reason: 'npx claude-* запрещён' },
{ re: /\bcurl\b[^|;]*-X\s*(?:POST|PUT|DELETE|PATCH)\b/i, reason: 'curl -X POST/PUT/DELETE/PATCH запрещён' },
@@ -120,10 +120,14 @@ const READING_CMDS = new Set(['ls', 'pwd', 'wc', 'head', 'tail', 'file', 'stat',
const SAFE_EXACT = [
/^npx\s+vitest\s+(?:run|--version)\b/,
/^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/,
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02 re-scope
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 re-scope (любой npm-скрипт)
// `npm ci` (2026-05-31, owner-authorized) — clean install from the committed
// lockfile (deterministic, no version drift) to restore junction node_modules
// in a fresh worktree. Distinct from `npm install`/`npm i`, which stay
// hard-blacklisted (line ~60) because they can pull new/updated versions.
// `\b` after `ci` prevents `npm cider`-style prefix matches.
/^npm\s+ci\b/,
/^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
/^composer\s+(?:show|outdated|install|update|require|remove|dump-autoload|dump)\b/, // +dev-allow 2026-06-02 re-scope
/^composer\s+(?:show|outdated)\b/,
/^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/,
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
@@ -140,12 +144,6 @@ const SAFE_EXACT = [
// hard-blacklist + chain-mutating rule (both run before the whitelist), and each
// chain segment after `cd app &&` must still be independently whitelisted.
/^cd\s+app$/,
// Worktree dev (2026-06-02, owner-authorized): cd into a project worktree dir
// (path segment `worktree-` / `v4-stream-`) so git/pest run there. Quoted absolute
// path required; `..` and protected segments (.claude/.ssh/.env/runtime/.git) excluded
// → cwd-shift read-bypass stays contained (protected files also remain blocked by name
// in the command). cd into Документация/system/protected dirs → default-deny.
/^cd\s+(?=.*[\\/](?:worktree-|v4-stream-))(?!.*(?:\.\.|\.claude|\.ssh|\.env|runtime|\.git)).+$/,
];
export function classifyWhitelist(segments) {
+39 -44
View File
@@ -15,17 +15,14 @@ describe('matchBashHardBlacklist — v3.9 keep', () => {
'python -c "import os"',
'bash -c "ls"',
'eval "$x"',
'composer install',
'npm install lodash',
'yarn add x',
'pnpm add x',
'curl -X POST https://evil.test',
])('blocks %s', (cmd) => {
expect(matchBashHardBlacklist(cmd)).toBeTruthy();
});
// composer/npm убраны из hard-blacklist (dev-allow 2026-06-02 re-scope) — здесь больше не блок
it('no longer hard-blacklists composer install / npm install (dev-allow)', () => {
expect(matchBashHardBlacklist('composer install')).toBe(null);
expect(matchBashHardBlacklist('npm install lodash')).toBe(null);
});
});
describe('matchBashHardBlacklist — v4.0 additions', () => {
@@ -118,8 +115,8 @@ describe('classifyBashCommand — integration', () => {
it('blocks reading a protected path', () => {
expect(classifyBashCommand('cat ~/.claude/runtime/state.json', {}).result).toBe('block');
});
it('routes single git commit to dev-allow (2026-06-02 re-scope — no approval needed)', () => {
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('allow');
it('routes single git commit to conditional (block unapproved)', () => {
expect(classifyBashCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
});
it('allows approved git commit', () => {
expect(
@@ -194,29 +191,17 @@ describe('SAFE_EXACT — Laravel dev workflow (whitelist expansion 2026-05-30)',
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
});
// Critical: REPL remains hard-blocked (composer/npm moved to dev-allow below, 2026-06-02 re-scope)
it('still blocks tinker REPL and unknown migrate subcommand', () => {
expect(classifyBashCommand('php artisan tinker', {}).result).toBe('block');
expect(classifyBashCommand('php artisan tinker --execute="exit"', {}).result).toBe('block');
expect(classifyBashCommand('php artisan migrate:install', {}).result).toBe('block');
});
// dev-allow (owner-authorized 2026-06-02 re-scope): composer is a local dev tool
it('now allows composer install/require/update/remove/dump-autoload', () => {
expect(classifyBashCommand('composer install', {}).result).toBe('allow');
expect(classifyBashCommand('composer install -d app --no-interaction', {}).result).toBe('allow');
expect(classifyBashCommand('composer require monolog/monolog', {}).result).toBe('allow');
expect(classifyBashCommand('composer update', {}).result).toBe('allow');
expect(classifyBashCommand('composer remove monolog/monolog', {}).result).toBe('allow');
expect(classifyBashCommand('composer dump-autoload', {}).result).toBe('allow');
});
// dev-allow (owner-authorized 2026-06-02 re-scope): npm is a local dev tool
it('now allows npm install/i/ci/run', () => {
expect(classifyBashCommand('npm install', {}).result).toBe('allow');
expect(classifyBashCommand('npm i', {}).result).toBe('allow');
expect(classifyBashCommand('npm ci', {}).result).toBe('allow');
expect(classifyBashCommand('npm run build', {}).result).toBe('allow');
// Critical: REPL and composer mutations remain hard-blocked
it.each([
['php artisan tinker', 'REPL = arbitrary PHP exec risk'],
['php artisan tinker --execute="exit"', 'tinker variant'],
['composer install', 'hard-blacklist'],
['composer require foo/bar', 'hard-blacklist'],
['composer update', 'hard-blacklist'],
['composer remove foo/bar', 'hard-blacklist'],
['php artisan migrate:install', 'unknown migrate subcommand outside whitelist set'],
])('still blocks %s (%s)', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('block');
});
// Critical: existing pre-existing v3.8 keep behaviour
@@ -286,27 +271,37 @@ describe('SAFE_EXACT — narrow `cd app` whitelist (2026-05-31, owner-authorized
});
});
describe('SAFE_EXACT — worktree cd (2026-06-02, owner-authorized worktree dev)', () => {
// Allowed: enter a project worktree dir (segment `worktree-` / `v4-stream-`) so
// git/pest can run there. Quoted absolute path; cwd-shift read-bypass stays contained
// because protected files remain blocked by name in the command (cat .env / runtime).
describe('SAFE_EXACT — npm ci (worktree dep restore, 2026-05-31)', () => {
// Allowed: npm ci installs exactly the committed lockfile (deterministic, no
// version drift) — needed to restore junction node_modules in a fresh worktree.
it.each([
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city"',
'cd "C:\\моя\\проекты\\портал crm\\worktree-deals-city\\app"',
'cd "C:\\моя\\проекты\\портал crm\\v4-stream-A"',
])('allows cd into a worktree dir: %s', (cmd) => {
'npm ci',
'npm ci --no-audit',
'npm ci --prefer-offline',
])('allows %s', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('allow');
});
// Scope: protected / non-worktree dirs stay default-deny (no `worktree-` marker, or
// `..` / protected segment present → cwd-shift read-bypass prevented).
// Critical: npm install / npm i remain hard-blacklisted (line 60) — they can
// pull new/updated versions, unlike ci which pins to the lockfile.
it.each([
'cd "C:\\Users\\Administrator\\.claude\\runtime"',
'cd "C:\\моя\\проекты\\портал crm\\worktree-x\\..\\..\\.claude"',
'cd "C:\\моя\\проекты\\портал crm\\Документация"',
])('still blocks cd into non-worktree / protected dir: %s', (cmd) => {
'npm install',
'npm i',
'npm install foo',
'npm i foo',
])('still blocks %s (hard-blacklist)', (cmd) => {
expect(classifyBashCommand(cmd, {}).result).toBe('block');
});
// Critical: word boundary — `npm cider` (or any ci-prefixed token) is NOT npm ci
it('does not allow ci-prefixed token (word boundary)', () => {
expect(classifyBashCommand('npm cider', {}).result).toBe('block');
});
// Critical: chain semantics still enforced — npm ci && rm x → block (rm mutating)
it('still blocks chain with mutating part after npm ci', () => {
expect(classifyBashCommand('npm ci && rm x', {}).result).toBe('block');
});
});
import { stripQuotedSpans } from './enforce-router-gate.mjs';
+13 -1
View File
@@ -24,6 +24,11 @@ import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
// Transcript protection (Z Part 1): any *.jsonl under ~/.claude/projects/** is a
// session/subagent transcript. The tdd-gate credits a subagent's RED from its
// agent-<id>.jsonl, so these must be unforgeable by the Write tool. Memory files
// there are *.md and never match `.jsonl$`, so memory writes stay allowed.
const TRANSCRIPT_RE = /(^|\/)\.claude\/projects\/.*\.jsonl$/i;
/**
* Pure decision.
@@ -39,12 +44,19 @@ export function decide({ toolName, filePath, normalizeImpl = pathNormalize }) {
if (!fp) return { block: false };
let norm;
try { norm = normalizeImpl(fp); } catch { return { block: false }; } // cannot determine → fail-open
if (RUNTIME_RE.test(String(norm || ''))) {
const normStr = String(norm || '');
if (RUNTIME_RE.test(normStr)) {
return {
block: true,
reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (git-approval anchor). Hooks write it via Node fs, not the Write tool.`,
};
}
if (TRANSCRIPT_RE.test(normStr)) {
return {
block: true,
reason: `Write to «${norm}» denied — ~/.claude/projects/**/*.jsonl are session/subagent transcripts (tamper-protected; the tdd-gate trusts them). The harness writes transcripts, never the Write tool. Memory *.md there stays writable.`,
};
}
return { block: false };
}
+44
View File
@@ -52,3 +52,47 @@ describe('enforce-runtime-write-deny decide()', () => {
expect(r.block).toBe(true);
});
});
// Part 1 of Z (2026-05-31): close the transcript Write hole. The tdd-gate will
// (Part 2) credit a subagent's RED from its agent-<id>.jsonl; that transcript
// must therefore be unforgeable. The Write tool was the last ungated channel
// into ~/.claude/projects/**/*.jsonl (Bash/PowerShell/Read gates already cover
// it). Memory files there are .md and stay writable (they never match .jsonl$).
describe('enforce-runtime-write-deny — transcript .jsonl protection (Z Part 1)', () => {
it('blocks a Write to a subagent transcript under ~/.claude/projects', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess-uuid', 'subagents', 'agent-abc.jsonl');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(true);
});
it('blocks a Write to the controller session transcript itself', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess-uuid.jsonl');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(true);
});
it('blocks Edit/MultiEdit/NotebookEdit on a transcript .jsonl too', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess', 'subagents', 'agent-x.jsonl');
expect(decide({ toolName: 'Edit', filePath: p }).block).toBe(true);
expect(decide({ toolName: 'MultiEdit', filePath: p }).block).toBe(true);
expect(decide({ toolName: 'NotebookEdit', filePath: p }).block).toBe(true);
});
it('blocks the .-segment evasion into projects transcripts', () => {
const evasion = `${HOME_FWD}/.claude/projects/slug/./sess/subagents/agent-x.jsonl`;
expect(decide({ toolName: 'Write', filePath: evasion }).block).toBe(true);
});
it('ALLOWS a memory .md under ~/.claude/projects (never a .jsonl)', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'memory', 'feedback_x.md');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(false);
});
it('ALLOWS a .jsonl OUTSIDE ~/.claude/projects (e.g. repo observer episodes)', () => {
const p = join(HOME, 'repo', 'docs', 'observer', 'episodes-2026-05.jsonl');
expect(decide({ toolName: 'Write', filePath: p }).block).toBe(false);
});
it('ignores non-write tools on a transcript path', () => {
const p = join(HOME, '.claude', 'projects', 'slug', 'sess', 'subagents', 'agent-x.jsonl');
expect(decide({ toolName: 'Read', filePath: p }).block).toBe(false);
});
});
+75 -12
View File
@@ -27,6 +27,7 @@ import {
isProductionCodePath,
readRouterState,
} from './enforce-hook-helpers.mjs';
import { join, dirname, basename } from 'node:path';
const RULE_KEY_TDD = 'tdd-gate';
const RULE_KEY_PLAN = 'writing-plans-required';
@@ -108,11 +109,6 @@ function hasFailingTestRun(turn) {
// Numeric: "Tests N failed | M passed" with N>0
const m = txt.match(/Tests\s+(\d+)\s+failed/);
if (m && Number(m[1]) > 0) return true;
// JSON reporter (composer test / php artisan test → pest): {"result":"failed",...}
// or {"failed":N}/{"errors":N} with N>0. command-not-found / error REDs lack the
// English "Failed" keyword above, so recognise the structured marker too.
if (/"result"\s*:\s*"failed"/.test(txt)) return true;
if (/"(?:failed|errors)"\s*:\s*[1-9]/.test(txt)) return true;
}
}
}
@@ -137,8 +133,56 @@ function hasPlanIndicator(turn) {
return false;
}
const AGENT_ID_RE = /agentId:\s*([0-9a-f]+)/i;
/**
* Cross-actor (Z Part 2): extract agentIds of subagents spawned by a `Task`
* tool in the controller's current turn. The agentId comes from the harness-
* written Task tool_result text ("agentId: <hex>") the controller cannot forge
* a tool_result in its own transcript. Only hex ids are accepted, so a crafted
* "agentId: ../../x" cannot become a path-traversal into an arbitrary file.
*/
export function turnTaskAgentIds(turn) {
const taskUseIds = new Set();
for (const e of turn || []) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (b && b.type === 'tool_use' && b.name === 'Task') taskUseIds.add(b.id);
}
}
const ids = [];
for (const e of turn || []) {
const c = e && e.message && e.message.content;
if (!Array.isArray(c)) continue;
for (const b of c) {
if (!b || b.type !== 'tool_result' || !taskUseIds.has(b.tool_use_id)) continue;
const txt = typeof b.content === 'string' ? b.content
: Array.isArray(b.content) ? b.content.map((p) => p && p.text).filter(Boolean).join('\n') : '';
const m = txt.match(AGENT_ID_RE);
if (m) ids.push(m[1]);
}
}
return ids;
}
/**
* Derive subagent transcript paths from the controller transcript path and a
* list of agentIds. Subagent transcripts live at
* <projects>/<slug>/<controller-session>/subagents/agent-<agentId>.jsonl
* i.e. nested under the controller session's own directory (bound to it), while
* the controller transcript is <...>/<controller-session>.jsonl.
*/
export function subagentTranscriptPaths(controllerTranscriptPath, agentIds) {
const p = String(controllerTranscriptPath || '');
if (!p) return [];
const dir = dirname(p);
const base = basename(p).replace(/\.jsonl$/i, '');
return (agentIds || []).map((id) => join(dir, base, 'subagents', `agent-${id}.jsonl`));
}
export function decide({
toolName, filePath, transcriptEntries, classification, override, overridePlan,
toolName, filePath, transcriptEntries, classification, override, overridePlan, subagentEntriesList = [],
}) {
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) return { block: false };
if (!isProductionCodePath(filePath)) return { block: false };
@@ -160,24 +204,31 @@ export function decide({
}
}
// Rule #3 — TDD gate.
// Rule #3 — TDD gate. Credit the controller's own turn OR a subagent that was
// spawned by a Task in this turn (cross-actor, Z Part 2). Subagent evidence is
// read from its agent-<id>.jsonl, which is tamper-protected by the transcript
// Write-deny (Z Part 1) — so crediting it does not open a forgery channel.
if (override) return { block: false };
const hasTest = hasMatchingTestEdit(turn, filePath);
const subList = Array.isArray(subagentEntriesList) ? subagentEntriesList : [];
const hasTest = hasMatchingTestEdit(turn, filePath) || subList.some((es) => hasMatchingTestEdit(es, filePath));
if (!hasTest) {
return {
block: true,
message: [
`[enforce-tdd-gate] Production code edit on "${filePath}" without preceding test edit.`,
`Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php.`,
`Write the failing test FIRST in the corresponding *.test.mjs / *.spec.ts / *Test.php`,
`(a subagent's test edit, if it was spawned by a Task in this turn, is also credited).`,
`Then run vitest/pest to confirm RED, then return to this prod-code Edit.`,
].join('\n'),
};
}
if (!hasFailingTestRun(turn)) {
const hasRed = hasFailingTestRun(turn) || subList.some((es) => hasFailingTestRun(es));
if (!hasRed) {
return {
block: true,
message: [
`[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn.`,
`[enforce-tdd-gate] Test was edited but no vitest/pest run with RED output observed in this turn`,
`(nor in any in-turn subagent transcript).`,
`Run the test suite (vitest run <test-file> / composer test) to confirm RED before prod-code edit.`,
].join('\n'),
};
@@ -204,7 +255,19 @@ async function main() {
task_type: state.classification.task_type,
} : null;
const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan });
// Cross-actor (Z Part 2): read transcripts of subagents spawned by a Task in
// this turn, bound to the controller session via the derived path. Best-effort
// — a missing/unreadable subagent transcript just yields no extra credit
// (stricter), never an error.
let subagentEntriesList = [];
try {
const turn = lastTurnEntries(transcript);
const agentIds = turnTaskAgentIds(turn);
const paths = subagentTranscriptPaths(event.transcript_path, agentIds);
subagentEntriesList = paths.map((p) => readTranscript(p)).filter((e) => Array.isArray(e) && e.length);
} catch { subagentEntriesList = []; }
const result = decide({ toolName, filePath, transcriptEntries: transcript, classification, override, overridePlan, subagentEntriesList });
exitDecision(result);
} catch {
exitDecision({ block: false });
+75 -23
View File
@@ -1,5 +1,79 @@
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-tdd-gate.mjs';
import { decide, turnTaskAgentIds, subagentTranscriptPaths } from './enforce-tdd-gate.mjs';
// Z Part 2 (2026-05-31): the tdd-gate must credit a subagent's test edit + RED
// when that subagent was spawned by a Task in the controller's current turn.
// Pairs with the transcript Write-hole closed in enforce-runtime-write-deny.mjs
// (Z Part 1) so the credited agent-<id>.jsonl cannot be forged.
describe('enforce-tdd-gate Z cross-actor (pairs with enforce-runtime-write-deny Part 1)', () => {
const subagentRedRun = [
{ message: { role: 'user', content: 'write the failing test for foo and confirm RED' } },
{ message: { role: 'assistant', content: [
{ type: 'tool_use', id: 's1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } },
{ type: 'tool_use', id: 's2', name: 'Bash', input: { command: 'npx vitest run tools/foo.test.mjs' } },
] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 's2', content: 'Tests 1 failed | 0 passed' } ] } },
];
it('credits a subagent test edit + RED for the controller prod edit', () => {
const r = decide({
toolName: 'Edit',
filePath: 'tools/foo.mjs',
transcriptEntries: [
{ message: { role: 'user', content: 'delegate the test, then I implement' } },
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Task', input: { subagent_type: 'tester' } } ] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: 'done. agentId: a1234abcd' } ] } },
],
subagentEntriesList: [subagentRedRun],
});
expect(r.block).toBe(false);
});
it('still blocks when subagent edited a test but NO RED exists anywhere', () => {
const subNoRed = [
{ message: { role: 'user', content: 'write test' } },
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 's1', name: 'Write', input: { file_path: 'tools/foo.test.mjs' } } ] } },
];
const r = decide({
toolName: 'Edit', filePath: 'tools/foo.mjs',
transcriptEntries: [ { message: { role: 'user', content: 'go' } } ],
subagentEntriesList: [subNoRed],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/RED/);
});
it('preserves old behavior when no subagent entries (blocks without test)', () => {
const r = decide({
toolName: 'Edit', filePath: 'tools/foo.mjs',
transcriptEntries: [ { message: { role: 'user', content: 'go' } } ],
subagentEntriesList: [],
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/without preceding test edit/);
});
it('turnTaskAgentIds extracts a hex agentId from an in-turn Task tool_result', () => {
const turn = [
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 't1', name: 'Task', input: {} } ] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 't1', content: 'ok agentId: a1b2c3d4e5' } ] } },
];
expect(turnTaskAgentIds(turn)).toContain('a1b2c3d4e5');
});
it('turnTaskAgentIds ignores non-Task results and rejects non-hex ids (no path traversal)', () => {
const turn = [
{ message: { role: 'assistant', content: [ { type: 'tool_use', id: 'b1', name: 'Bash', input: {} } ] } },
{ message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'b1', content: 'agentId: ../../evil' } ] } },
];
expect(turnTaskAgentIds(turn)).toHaveLength(0);
});
it('subagentTranscriptPaths derives <dir>/<sessbase>/subagents/agent-<id>.jsonl', () => {
const paths = subagentTranscriptPaths('/p/projects/slug/sessUUID.jsonl', ['a1b2']);
expect(paths[0].split('\\').join('/')).toBe('/p/projects/slug/sessUUID/subagents/agent-a1b2.jsonl');
});
});
function userMsg(text) {
return { message: { role: 'user', content: text } };
@@ -168,25 +242,3 @@ describe('enforce-tdd-gate / decide', () => {
expect(r.block).toBe(false);
});
});
describe('enforce-tdd-gate / decide — JSON pest reporter RED (composer test)', () => {
// `composer test` (php artisan test) emits machine JSON like {"result":"failed",...}.
// command-not-found / error REDs lack the English "Failed" keyword, so the gate must
// recognise the structured failure marker, else legit RED runs go unseen.
it('recognizes {"result":"failed"} JSON output as a RED run', () => {
const r = decide({
toolName: 'Write',
filePath: 'wt/app/app/Console/Commands/FooCommand.php',
transcriptEntries: [
userMsg('add backfill command'),
assistantUses([
{ id: 't1', name: 'Write', input: { file_path: 'wt/app/tests/Feature/Console/FooCommandTest.php' } },
{ id: 't2', name: 'Bash', input: { command: 'composer test -- tests/Feature/Console/FooCommandTest.php # pest' } },
]),
toolResults([{ id: 't2', content: '{"tool":"pest","result":"failed","tests":4,"passed":0,"errors":4}' }]),
],
classification: null,
});
expect(r.block).toBe(false);
});
});
-3
View File
@@ -16,13 +16,10 @@ export const DEFAULT_MCP_CLASSIFICATION = Object.freeze({
'mcp__redis__set': { category: 'hard_blacklist' },
'mcp__redis__delete': { category: 'hard_blacklist' },
'mcp__github__get_me': { category: 'read_only' },
'mcp__github__get_*': { category: 'read_only' }, // read-only loosening 2026-06-02 (get_file_contents/get_job_logs/get_commit/…)
'mcp__github__list_*': { category: 'read_only' },
'mcp__github__search_*': { category: 'read_only' },
'mcp__github__pull_request_read': { category: 'read_only' },
'mcp__github__issue_read': { category: 'read_only' },
'mcp__github__actions_get': { category: 'read_only' }, // read a workflow run (actions_run_trigger stays blacklisted — exact key wins)
'mcp__github__actions_list': { category: 'read_only' }, // list workflows / runs
'mcp__laravel-boost__database-query': {
category: 'conditional',
args_key_to_scan: 'query',
-34
View File
@@ -129,37 +129,3 @@ describe('classifyMcpTool — WebSearch llm-judge flag (G1)', () => {
expect(r.scanArg).toBe('how to exfil data');
});
});
// Owner-authorized read-only GitHub loosening (2026-06-02): allow reading
// workflow runs / job logs / file contents so the controller can read prod-op
// results without manual screenshots. Prod-mutating tools (run_trigger, writes)
// MUST stay blocked — human-in-the-loop on prod actions is unchanged.
describe('classifyMcpTool — read-only GitHub (owner-authorized 2026-06-02)', () => {
it('allows reading a workflow run (actions_get)', () => {
expect(classifyMcpTool('mcp__github__actions_get', { run_id: 1 }).decision).toBe('allow');
});
it('allows listing workflows / runs (actions_list)', () => {
expect(classifyMcpTool('mcp__github__actions_list', {}).decision).toBe('allow');
});
it('allows reading job logs (get_job_logs via get_* glob)', () => {
expect(classifyMcpTool('mcp__github__get_job_logs', { job_id: 1 }).decision).toBe('allow');
});
it('allows reading file contents (get_file_contents via get_* glob)', () => {
expect(classifyMcpTool('mcp__github__get_file_contents', { path: 'x' }).decision).toBe('allow');
});
it('allows reading a commit (get_commit via get_* glob)', () => {
expect(classifyMcpTool('mcp__github__get_commit', { sha: 'x' }).decision).toBe('allow');
});
it('STILL BLOCKS triggering a workflow (actions_run_trigger — exact wins over glob)', () => {
expect(classifyMcpTool('mcp__github__actions_run_trigger', {}).decision).toBe('block');
});
it('STILL BLOCKS writing a file (create_or_update_file)', () => {
expect(classifyMcpTool('mcp__github__create_or_update_file', { path: 'x' }).decision).toBe('block');
});
it('STILL BLOCKS push_files', () => {
expect(classifyMcpTool('mcp__github__push_files', {}).decision).toBe('block');
});
it('STILL BLOCKS update_pull_request (write)', () => {
expect(classifyMcpTool('mcp__github__update_pull_request', {}).decision).toBe('block');
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ export function computeWorkspaceHash(workspacePath) {
return createHash('md5').update(String(workspacePath || ''), 'utf-8').digest('hex').slice(0, 12);
}
function isStale(record, now) {
export function isStale(record, now) {
if (!record || typeof record !== 'object') return true;
const ttl = typeof record.ttl_ms === 'number' ? record.ttl_ms : LOCK_DEFAULT_TTL_MS;
return now - (record.acquired_at || 0) > ttl;
+21
View File
@@ -6,6 +6,7 @@ import {
release,
refresh,
computeWorkspaceHash,
isStale,
LOCK_DEFAULT_TTL_MS,
} from './parallel-session-lock.mjs';
@@ -91,6 +92,26 @@ describe('parallel-session-lock pure module (Stream H Task 7)', () => {
});
});
// isStale is exported (B, 2026-05-31) so the wrapper's prune step reuses the
// EXACT same staleness definition — single source of truth, no divergence that
// could ever prune a still-fresh (active) lock.
describe('isStale (exported for prune support)', () => {
it('true when now - acquired_at exceeds ttl_ms', () => {
expect(isStale({ acquired_at: 0, ttl_ms: 100 }, 1000)).toBe(true);
});
it('false when still within ttl (active lock — never pruned)', () => {
expect(isStale({ acquired_at: 900, ttl_ms: 1000 }, 1000)).toBe(false);
});
it('true for a malformed/missing record', () => {
expect(isStale(null, 1000)).toBe(true);
expect(isStale(undefined, 1000)).toBe(true);
});
it('uses the default TTL when ttl_ms is absent', () => {
expect(isStale({ acquired_at: 0 }, LOCK_DEFAULT_TTL_MS + 1)).toBe(true);
expect(isStale({ acquired_at: 0 }, LOCK_DEFAULT_TTL_MS - 1)).toBe(false);
});
});
describe('computeWorkspaceHash (Stream H Task 7)', () => {
it('returns 12 hex chars', () => {
const h = computeWorkspaceHash('/some/path');
+5 -30
View File
@@ -164,13 +164,9 @@ const GIT_READONLY_SUB = new Set([
'rev-parse', 'merge-base', 'remote', 'stash', // stash list/show resolved below
'fetch', 'ls-remote', // ref-only, no working-tree mutation — Stream H pre-flight requires §15.2 sync
]);
// dev-safe (owner-authorized 2026-06-02 re-scope): allow без approval. GIT_HARD_PATTERNS
// (--no-verify / add -f / -c / force / --output / -o) пре-фильтруют опасные варианты ВЫШЕ.
const GIT_DEV_SUB = new Set([
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
]);
const GIT_CONDITIONAL_SUB = new Set([
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', 'clean',
'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean',
]);
// G5/G6 + force-push + add -f → always block (даже если "approved").
@@ -187,23 +183,14 @@ const GIT_HARD_PATTERNS = [
];
function gitSubcommand(command) {
// Skip leading global flags `-c <val>` and `-C <path>`. `git -C <dir> <sub>` is the
// cwd-independent way to operate on a worktree (the shell resets cwd each call), so the
// real subcommand must be found after `-C`. `-C` (uppercase, working-dir) is case-distinct
// from the blocked `-c` config-injection (GIT_HARD_PATTERNS still scans the full command).
const m = normalizeCommand(command).match(
/\bgit\s+(?:(?:-c\s+\S+|-C\s+(?:"[^"]*"|'[^']*'|\S+))\s+)*([a-z][\w-]*)/,
);
const m = normalizeCommand(command).match(/\bgit\s+(?:-c\s+\S+\s+)*([a-z][\w-]*)/);
return m ? m[1] : null;
}
export function classifyGitCommand(command, ctx = {}) {
// Strip a leading `git -C <path>` (worktree-dir flag) so every rule below sees the real
// subcommand+flags. Without this, position-anchored hard-patterns (--no-verify / --force /
// add -f) and the push-main-guard would be bypassed by interposing `-C <dir>`.
const norm = normalizeCommand(command).replace(/(\bgit)\s+-C\s+(?:"[^"]*"|'[^']*'|\S+)\s+/, '$1 ');
const norm = normalizeCommand(command);
if (!/\bgit\b/.test(norm)) return null;
const sub = gitSubcommand(norm);
const sub = gitSubcommand(command);
if (!sub) return null;
// 1. git-hard — block безусловно
@@ -225,18 +212,6 @@ export function classifyGitCommand(command, ctx = {}) {
return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' };
}
// dev-safe git (owner-authorized 2026-06-02 re-scope): GIT_HARD_PATTERNS уже отсеяли
// опасные варианты (--no-verify / add -f / -c / force / --output / -o) на шаге 1.
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
if (sub === 'push') {
if (/\b(?:main|master)\b/.test(norm)) {
return { result: 'block', reason: 'git push в main/master — клик владельца' };
}
return { result: 'allow', reason: 'git push в фичевую ветку' };
}
// 3. conditional → approve check
if (GIT_CONDITIONAL_SUB.has(sub)) {
const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now());
+24 -65
View File
@@ -167,78 +167,37 @@ describe('classifyGitCommand — readonly', () => {
);
});
describe('classifyGitCommand — conditional (still needs approval after 2026-06-02 re-scope)', () => {
describe('classifyGitCommand — conditional after approve', () => {
const now = 2_000_000;
it('blocks unapproved rebase/reset/merge/cherry-pick/revert/pull/clean', () => {
for (const cmd of ['git rebase main', 'git reset --hard', 'git merge feat',
'git cherry-pick abc', 'git revert abc', 'git pull', 'git clean -fd']) {
expect(classifyGitCommand(cmd, { approvedGitOps: [], now }).result).toBe('block');
}
it('blocks unapproved git commit', () => {
const r = classifyGitCommand('git commit -m "x"', { approvedGitOps: [], now });
expect(r.result).toBe('block');
expect(r.reason).toMatch(/approve/i);
});
it('allows approved git merge', () => {
const r = classifyGitCommand('git merge feat', {
approvedGitOps: [{ command: 'git merge feat', ts: now }],
it('allows approved git commit', () => {
const r = classifyGitCommand('git commit -m "x"', {
approvedGitOps: [{ command: 'git commit -m "x"', ts: now }],
now,
});
expect(r.result).toBe('allow');
});
});
describe('classifyGitCommand — dev-allow (owner-authorized 2026-06-02 re-scope)', () => {
const na = { approvedGitOps: [], now: 2_000_000 };
it('allows commit/add/branch/switch/checkout/stash/worktree without approval', () => {
for (const cmd of [
'git commit -m "x"', 'git add .', 'git branch feature-x',
'git switch -c feature-x', 'git switch feature-x', 'git checkout -b feature-x',
'git stash push -m wip', 'git stash pop',
'git worktree add ../wt -b feat origin/main',
]) {
expect(classifyGitCommand(cmd, na).result).toBe('allow');
}
it.each(['git rebase main', 'git reset --hard', 'git switch main', 'git stash pop', 'git push origin feat'])(
'blocks unapproved %s',
(cmd) => {
expect(classifyGitCommand(cmd, { approvedGitOps: [], now }).result).toBe('block');
},
);
it('blocks unapproved git add (v4 Stream G addition)', () => {
const r = classifyGitCommand('git add .claude/settings.json', { approvedGitOps: [], now });
expect(r.result).toBe('block');
expect(r.reason).toMatch(/approve/i);
});
it('still blocks commit --no-verify and add -f (hard patterns survive dev-allow)', () => {
expect(classifyGitCommand('git commit --no-verify -m x', na).result).toBe('block');
expect(classifyGitCommand('git add -f ignored.txt', na).result).toBe('block');
});
});
describe('classifyGitCommand — push main-guard (owner-authorized 2026-06-02 re-scope)', () => {
const na = { approvedGitOps: [], now: 2_000_000 };
it('allows push to a feature branch / bare push', () => {
expect(classifyGitCommand('git push origin worktree-lead-region-tails', na).result).toBe('allow');
expect(classifyGitCommand('git push', na).result).toBe('allow');
expect(classifyGitCommand('git push -u origin feature-x', na).result).toBe('allow');
});
it('blocks push to main/master (owner click)', () => {
expect(classifyGitCommand('git push origin main', na).result).toBe('block');
expect(classifyGitCommand('git push origin HEAD:main', na).result).toBe('block');
expect(classifyGitCommand('git push origin master', na).result).toBe('block');
});
it('blocks force-push (hard pattern unchanged)', () => {
expect(classifyGitCommand('git push --force origin feature-x', na).result).toBe('block');
expect(classifyGitCommand('git push origin feature-x --force-with-lease', na).result).toBe('block');
});
});
describe('classifyGitCommand — git -C <path> (worktree dev, 2026-06-02)', () => {
const na = { approvedGitOps: [], now: 4_000_000 };
// git -C points git at another working tree (cwd resets each shell call, so this is
// the cwd-independent way to commit in a worktree). Classify by the REAL subcommand
// after -C, with all hard-patterns / push-main-guard still applied to the full command.
it.each([
'git -C "C:\\моя\\проекты\\портал crm\\worktree-x" commit -m "y"',
'git -C "C:\\моя\\проекты\\портал crm\\worktree-x" add app/foo.php',
'git -C "/path/worktree-x" push origin feature-y',
'git -C /repo status',
])('classifies by real subcommand after -C: %s', (cmd) => {
expect(classifyGitCommand(cmd, na).result).toBe('allow');
});
it('still blocks push to main even with -C', () => {
expect(classifyGitCommand('git -C /repo push origin main', na).result).toBe('block');
});
it('still blocks --no-verify even with -C', () => {
expect(classifyGitCommand('git -C /repo commit --no-verify -m x', na).result).toBe('block');
it('allows approved git add', () => {
const r = classifyGitCommand('git add .claude/settings.json', {
approvedGitOps: [{ command: 'git add .claude/settings.json', ts: now }],
now,
});
expect(r.result).toBe('allow');
});
});