Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e452f2232 | |||
| 69f454b28a | |||
| 4b37a099b4 | |||
| da83b27cc7 | |||
| 2372db71e0 | |||
| d0d05d4fcc | |||
| a3998f0d6e | |||
| 9b97bc55ca | |||
| 5012f1585e | |||
| 60ab5be3eb | |||
| a299377fd7 | |||
| abf668c5c8 | |||
| 5a4ccbcbe8 | |||
| 4c24ea28df | |||
| 8706e21db7 | |||
| 9bdf0f4875 | |||
| 12ac53dfa2 | |||
| f3e79378f0 | |||
| 071bf1618c | |||
| 9cc4465b6a | |||
| 89fd9d0e42 | |||
| c3924163fb | |||
| 30af7a80d9 | |||
| 298b900c5a | |||
| aad48de6f6 | |||
| 7c3a246759 | |||
| ec54cda394 | |||
| f4602b4aa5 | |||
| 6a9df652ff | |||
| 6192d395e4 | |||
| 3ecb0134bd | |||
| 7fdf0ba971 | |||
| 4665c537e8 | |||
| c7d61a6adc | |||
| 705608b5ad | |||
| 99b758a4f4 | |||
| 7a9fef3785 | |||
| f5482f415c | |||
| 11822e3803 | |||
| 77e98afaa6 | |||
| 963379c3d9 | |||
| 596371e977 | |||
| 527f628a21 | |||
| 33462bf52e | |||
| c76038d076 | |||
| 970648b3fd | |||
| 866bf1765e | |||
| 86d8e25cb4 | |||
| ccb2efe339 | |||
| a195611d85 | |||
| 378cfba406 | |||
| d170c886bc | |||
| 0da70af053 | |||
| cfe94d9178 | |||
| fb4e711b4a | |||
| 0539951d6b | |||
| 0a641ba44f | |||
| 4a64d6a7e1 | |||
| 390cc98f94 | |||
| 298cbb3502 | |||
| 31435b4b98 | |||
| a296a499d9 | |||
| 3fde7f1dd5 | |||
| a2f6714440 | |||
| 1154c9752b | |||
| 146501bae9 | |||
| ce314034b4 | |||
| 6319230ab8 |
@@ -27,6 +27,33 @@ YYYY-MM-DD .. YYYY-MM-DD ({N} sessions)
|
||||
| node | times used | first / last |
|
||||
|---|---|---|
|
||||
|
||||
## Hook script breakdown (from `hook_fired.scripts`, schema v3+)
|
||||
|
||||
Per-script counts across the period. Surfaces which discipline-enforcing hooks fired (and which silently failed to fire). Aggregate from `events[].hook_fired.scripts` of v3 episodes — v2 episodes have only matcher-level `counts` and contribute nothing here.
|
||||
|
||||
| script | times fired | notes |
|
||||
|---|---|---|
|
||||
| `tools/observer-stop-hook.mjs` | N | should fire once per turn — gaps = observer drop |
|
||||
| `tools/subagent-prompt-prefix.mjs` | N | once per Task-tool call |
|
||||
| `inline:<sha-16>` | N | inline `node -e "..."` — see settings.json for body |
|
||||
|
||||
**Discipline highlights:**
|
||||
|
||||
- `tools/observer-stop-hook.mjs` count < turn count → observer skipped turns; cross-check `observerErrorCount` and STATUS.md C5.
|
||||
- `tools/subagent-prompt-prefix.mjs` count vs `Agent` tool_use count — mismatch = missing pre-flight injection.
|
||||
- Inline `claude-md`/`schema.sql` guards — fired iff someone touched those files.
|
||||
|
||||
## Recommended-node candidates (from `primary_rationale.recommended_node`, schema v3+)
|
||||
|
||||
Distinct from `missedActivations` (which aggregates): this is the per-episode signal embedded in each direct episode.
|
||||
|
||||
| recommended_node | times direct | top classifications |
|
||||
|---|---|---|
|
||||
| #19 | N | feature, planning |
|
||||
| none (v2 or no recommendation) | N | — |
|
||||
|
||||
Cross-reference with `factorMatrix.recommended_node_for_direct` and `missedActivations.byNode`. A persistent (#NN, count > threshold) — strong missed-activation pattern, candidate for retro discussion.
|
||||
|
||||
## Factor analysis matrix (v2 — from `tools/brain-retro-analyzer.mjs`)
|
||||
|
||||
Outcome distribution per factor value. Source: the analyzer’s `factorMatrix`.
|
||||
@@ -81,6 +108,8 @@ Surface candidates where a profile-classified task ran with `node_chosen === 'di
|
||||
|
||||
**NOT to be auto-applied:** these are candidates for human review in retro, not commits or hook blocks.
|
||||
|
||||
**Schema v3 NB:** since 2026-05-23, each direct episode carries `primary_rationale.recommended_node` directly. The analyzer's `missedActivations` aggregates these into `byNode`/`byClassification`. For per-episode forensics (which prompt, which session), grep episodes-*.jsonl on `"recommended_node":"#NN"`.
|
||||
|
||||
## Episodes → tasks (from analyzer `tasks`)
|
||||
|
||||
| task_ref | episodes | turns that are rework |
|
||||
|
||||
@@ -24,3 +24,18 @@ f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth
|
||||
# 2026-05-22 — nuclei-setup.md curl-auth-user тот же FP что и раньше (f696ca5),
|
||||
# но коммит другой (05437ba) — параллельная сессия пере-коммитила тот же файл.
|
||||
05437ba79a26a7a7bbbe0ffb2f2573c432a9a4d1:docs/security/nuclei-setup.md:curl-auth-user:27
|
||||
|
||||
# 2026-05-23 — ru-phone-unmasked в УЖЕ ЗАПУШЕННОЙ истории (origin/main a2f67144 + старее).
|
||||
# ПИЛОТ.md: "79135XXXXXX" — НЕ ПДн клиента, а телефон-style мусор, который поставщик
|
||||
# crm.bp-gr.ru кладёт в колонку названия проекта в CSV (документирован как пример
|
||||
# лог-спама csv_reconcile.unparseable_project_skipped). В рабочей копии замаскирован
|
||||
# 23.05; исторические коммиты приняты (rewrite 1305-коммитной запушенной истории ради
|
||||
# supplier-мусора не оправдан). episodes.jsonl: observer-логи (в рабочей копии чисто).
|
||||
a2f6714440c925e8ffdec8667373511dcce1b3aa:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
1154c9752b61ba7b147a5725b471a5af7d61db56:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
a2f6714440c925e8ffdec8667373511dcce1b3aa:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
1154c9752b61ba7b147a5725b471a5af7d61db56:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:46
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:48
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:76
|
||||
|
||||
@@ -4,39 +4,70 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\IncidentDetectedMail;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Сканирует failed_webhook_jobs за скользящее окно и автоматически создаёт
|
||||
* incidents_log, когда кластер падений превышает заданный порог.
|
||||
* Сканирует failed_webhook_jobs и failed_jobs за скользящее окно.
|
||||
*
|
||||
* Запускается каждые 10 минут через Schedule (routes/console.php).
|
||||
* Дедупликация: если открытый инцидент с такой же сигнатурой создан менее
|
||||
* --dedup-window минут назад, новая запись не создаётся.
|
||||
* failed_webhook_jobs: одно правило — spike ≥ threshold (200).
|
||||
* failed_jobs: три правила:
|
||||
* - spike: кол-во за окно одного job-класса ≥ threshold-spike (10) → high
|
||||
* - daily-total: за 24ч одного job-класса ≥ threshold-daily (50) → medium
|
||||
* - persistent: один exception повторяется > persistent-hours часов → medium
|
||||
*
|
||||
* Дедуп: если открытый инцидент с той же сигнатурой создан < dedup-window мин —
|
||||
* пропускаем. Письмо на kdv1@bk.ru только для severity=high.
|
||||
*/
|
||||
class IncidentsWatchFailures extends Command
|
||||
{
|
||||
protected $signature = 'incidents:watch-failures
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог числа падений за окно}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
protected $description = 'Сканирует failed_webhook_jobs за окно и создаёт incidents_log на превышение порога';
|
||||
protected $signature = 'incidents:watch-failures
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
||||
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
||||
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
||||
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
|
||||
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$windowMinutes = (int) $this->option('window');
|
||||
$threshold = (int) $this->option('threshold');
|
||||
$thresholdSpike = (int) $this->option('threshold-spike');
|
||||
$thresholdDaily = (int) $this->option('threshold-daily');
|
||||
$persistentHours = (int) $this->option('persistent-hours');
|
||||
$dedupMinutes = (int) $this->option('dedup-window');
|
||||
|
||||
$since = Carbon::now()->subMinutes($windowMinutes);
|
||||
$since24h = Carbon::now()->subHours(24);
|
||||
$dedupAt = Carbon::now()->subMinutes($dedupMinutes);
|
||||
$now = Carbon::now();
|
||||
|
||||
// Группируем упавшие (ещё не resolved) джобы за окно по сигнатуре
|
||||
$groups = DB::table('failed_webhook_jobs')
|
||||
// --- Проверяем наличие SaaS-администратора (FK NOT NULL) ---
|
||||
$adminId = DB::connection(self::DB_CONNECTION)
|
||||
->table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
$this->warn('No active saas_admin_users found — skipping incident creation (warn-only).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
|
||||
// ===== БЛОК 1: failed_webhook_jobs (исходная логика) =====
|
||||
$webhookGroups = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_webhook_jobs')
|
||||
->selectRaw('LEFT(exception, 180) AS sig, COUNT(*) AS cnt')
|
||||
->whereNull('resolved_at')
|
||||
->where('failed_at', '>=', $since)
|
||||
@@ -44,63 +75,156 @@ class IncidentsWatchFailures extends Command
|
||||
->havingRaw('COUNT(*) >= ?', [$threshold])
|
||||
->get();
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
$this->info('No failure spikes detected.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Получаем ID первого доступного SaaS-администратора (для NOT NULL FK)
|
||||
$adminId = DB::table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
$this->error('No active saas_admin_users found — cannot create incidents_log rows.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
|
||||
foreach ($groups as $group) {
|
||||
foreach ($webhookGroups as $group) {
|
||||
$sig = $group->sig;
|
||||
$count = (int) $group->cnt;
|
||||
$dedupKey = substr($sig, 0, 80);
|
||||
|
||||
// Дедупликация: есть ли уже открытый инцидент с такой сигнатурой?
|
||||
$alreadyOpen = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%'.addcslashes(substr($sig, 0, 80), '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupAt)
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
$this->line("Skipping (dedup): {$sig}");
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping webhook (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('incidents_log')->insert([
|
||||
'type' => 'other',
|
||||
'severity' => 'high',
|
||||
'summary' => "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. "
|
||||
."Сигнатура: {$sig}",
|
||||
'root_cause' => null,
|
||||
'started_at' => $since,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$summary = "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. Сигнатура: {$sig}";
|
||||
|
||||
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Incident created: [{$count} failures] {$sig}");
|
||||
$this->info("Webhook incident [high]: {$count} failures");
|
||||
}
|
||||
|
||||
// ===== БЛОК 2: failed_jobs — spike =====
|
||||
$spikes = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_jobs')
|
||||
->selectRaw(
|
||||
"payload::json->>'displayName' AS job_class, ".
|
||||
'LEFT(exception, 80) AS exc_sig, '.
|
||||
'COUNT(*) AS cnt'
|
||||
)
|
||||
->where('failed_at', '>=', $since)
|
||||
->groupByRaw("payload::json->>'displayName', LEFT(exception, 80)")
|
||||
->havingRaw('COUNT(*) >= ?', [$thresholdSpike])
|
||||
->get();
|
||||
|
||||
foreach ($spikes as $row) {
|
||||
$jobClass = (string) $row->job_class;
|
||||
$excSig = (string) $row->exc_sig;
|
||||
$cnt = (int) $row->cnt;
|
||||
$dedupKey = "spike:{$jobClass}:{$excSig}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping spike (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: spike {$cnt} failures job={$jobClass} за {$windowMinutes} мин. Exc: {$excSig}";
|
||||
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Job spike [high]: {$jobClass} — {$cnt}");
|
||||
}
|
||||
|
||||
// ===== БЛОК 3: failed_jobs — daily-total =====
|
||||
$daily = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_jobs')
|
||||
->selectRaw(
|
||||
"payload::json->>'displayName' AS job_class, ".
|
||||
'COUNT(*) AS cnt'
|
||||
)
|
||||
->where('failed_at', '>=', $since24h)
|
||||
->groupByRaw("payload::json->>'displayName'")
|
||||
->havingRaw('COUNT(*) >= ?', [$thresholdDaily])
|
||||
->get();
|
||||
|
||||
foreach ($daily as $row) {
|
||||
$jobClass = (string) $row->job_class;
|
||||
$cnt = (int) $row->cnt;
|
||||
$dedupKey = "daily:{$jobClass}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping daily (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: daily-total {$cnt} failures job={$jobClass} за 24ч";
|
||||
$this->createIncident($adminId, 'other', 'medium', $summary, $since24h, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Job daily [medium]: {$jobClass} — {$cnt}");
|
||||
}
|
||||
|
||||
// ===== БЛОК 4: failed_jobs — persistent =====
|
||||
$persistentSince = Carbon::now()->subHours($persistentHours);
|
||||
|
||||
$persistent = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_jobs')
|
||||
->selectRaw(
|
||||
"payload::json->>'displayName' AS job_class, ".
|
||||
'LEFT(exception, 80) AS exc_sig, '.
|
||||
'MIN(failed_at) AS oldest_at, '.
|
||||
'COUNT(*) AS cnt'
|
||||
)
|
||||
->where('failed_at', '<=', $persistentSince)
|
||||
->groupByRaw("payload::json->>'displayName', LEFT(exception, 80)")
|
||||
->get();
|
||||
|
||||
foreach ($persistent as $row) {
|
||||
$jobClass = (string) $row->job_class;
|
||||
$excSig = (string) $row->exc_sig;
|
||||
$dedupKey = "persistent:{$jobClass}:{$excSig}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping persistent (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: persistent exception job={$jobClass} повторяется >{$persistentHours}ч. Exc: {$excSig}";
|
||||
$this->createIncident($adminId, 'other', 'medium', $summary, Carbon::parse($row->oldest_at), $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Job persistent [medium]: {$jobClass}");
|
||||
}
|
||||
|
||||
$this->info("Done. Created {$created} incident(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function isDup(string $dedupKey, Carbon $dedupAt): bool
|
||||
{
|
||||
// Сигнатура сохраняется в root_cause для надёжного дедупа
|
||||
return DB::connection(self::DB_CONNECTION)
|
||||
->table('incidents_log')
|
||||
->where('root_cause', $dedupKey)
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupAt)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function createIncident(
|
||||
int $adminId,
|
||||
string $type,
|
||||
string $severity,
|
||||
string $summary,
|
||||
Carbon $startedAt,
|
||||
Carbon $now,
|
||||
string $dedupKey = '',
|
||||
): void {
|
||||
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
|
||||
'type' => $type,
|
||||
'severity' => $severity,
|
||||
'summary' => $summary,
|
||||
'root_cause' => $dedupKey !== '' ? $dedupKey : null,
|
||||
'started_at' => $startedAt,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($severity === 'high') {
|
||||
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,19 @@ use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs`
|
||||
* Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES
|
||||
* на N месяцев вперёд от текущей даты.
|
||||
*
|
||||
* Hole #2 (23.05.2026): расширен с 2 бизнес-таблиц до 9 (+ 7 audit-таблиц).
|
||||
*
|
||||
* Замена `pg_partman` на native Windows-стеке (расширение недоступно
|
||||
* без сборки из исходников). Запускается ежесуточно через Windows Task
|
||||
* Scheduler / cron — идемпотентна (CREATE TABLE IF NOT EXISTS).
|
||||
* Scheduler / cron — идемпотентна (проверяет pg_class перед CREATE).
|
||||
*
|
||||
* По дефолту 2 месяца вперёд (паритет с инициализацией schema.sql:
|
||||
* 6 партиций при `migrate:fresh`, последующие месяцы — этим cron'ом).
|
||||
*
|
||||
* Источник: db/schema.sql §5 (deals partition), §8.5 (supplier_lead_costs);
|
||||
* Источник: MonthlyPartitionManager::PARTITIONED_TABLES (единственный SoT списка таблиц);
|
||||
* project_phase1_strategy.md (pg_partman заменён ручным cron'ом).
|
||||
*/
|
||||
class PartitionsCreateMonths extends Command
|
||||
@@ -28,7 +30,7 @@ class PartitionsCreateMonths extends Command
|
||||
protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)';
|
||||
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)';
|
||||
|
||||
public function handle(MonthlyPartitionManager $manager): int
|
||||
{
|
||||
@@ -41,8 +43,8 @@ class PartitionsCreateMonths extends Command
|
||||
for ($i = 0; $i <= $ahead; $i++) {
|
||||
$monthStart = $now->copy()->addMonths($i);
|
||||
|
||||
foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) {
|
||||
$partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));
|
||||
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
|
||||
$partitionName = $manager->partitionName($table, $monthStart);
|
||||
|
||||
if ($manager->ensureMonth($table, $monthStart)) {
|
||||
$created++;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Удаляет устаревшие месячные партиции согласно retention-настройкам.
|
||||
*
|
||||
* Retention для каждой таблицы хранится в system_settings:
|
||||
* key = 'partition_retention_months_<table>'
|
||||
* value = количество месяцев (integer >= 1)
|
||||
*
|
||||
* Защита от опасных значений:
|
||||
* - NULL / отсутствие ключа → пропустить таблицу (не дропать ничего)
|
||||
* - 0 → пропустить (запрет стирания всего)
|
||||
* - < 0 → пропустить
|
||||
* - Минимальное значение, принятое к выполнению: 1 месяц
|
||||
*
|
||||
* Формат имени партиции: <table>_y<YYYY>_m<MM>
|
||||
* Партиция считается устаревшей, если её месяц < (текущий месяц − retention).
|
||||
*
|
||||
* Пример:
|
||||
* сейчас = 2026-05, retention = 3
|
||||
* cutoff = 2026-02 (включительно; т.е. 2026-01 и старее — дропать)
|
||||
* будет удалена: auth_log_y2026_m01, auth_log_y2025_m12, …
|
||||
* НЕ будет удалена: auth_log_y2026_m02 (граница), и всё новее
|
||||
*
|
||||
* Hole #2, 23.05.2026.
|
||||
*/
|
||||
class PartitionsDropExpired extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'partitions:drop-expired
|
||||
{--dry-run : Перечислить партиции для удаления, не удалять}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Удаляет устаревшие месячные партиции согласно system_settings (partition_retention_months_*)';
|
||||
|
||||
public function handle(MonthlyPartitionManager $manager): int
|
||||
{
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->line('<fg=yellow>Dry-run: партиции будут перечислены, но NOT удалены.</>');
|
||||
}
|
||||
|
||||
$now = Carbon::now()->startOfMonth();
|
||||
$totalDropped = 0;
|
||||
$totalSkipped = 0;
|
||||
|
||||
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
|
||||
$retention = $this->resolveRetention($table);
|
||||
|
||||
if ($retention === null) {
|
||||
$this->line(" <fg=gray>skip</> {$table}: retention not configured");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$partitions = $manager->listPartitions($table);
|
||||
|
||||
if (empty($partitions)) {
|
||||
$this->line(" <fg=gray>skip</> {$table}: no partitions exist yet");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dropped = 0;
|
||||
|
||||
foreach ($partitions as $partitionName) {
|
||||
$monthStart = $this->parsePartitionMonth($partitionName);
|
||||
|
||||
if ($monthStart === null) {
|
||||
// Имя не соответствует формату _yYYYY_mMM — не трогать (безопасность)
|
||||
$this->warn(" ? {$partitionName}: unrecognised name format, skipping");
|
||||
$totalSkipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Граница: всё строго старее (now - retention месяцев) — удалять.
|
||||
// Т.е. monthStart < cutoff, где cutoff = now - retention.
|
||||
$cutoff = $now->copy()->subMonths($retention);
|
||||
|
||||
if (! $monthStart->lessThan($cutoff)) {
|
||||
// Партиция ещё в пределах retention — оставить
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->line(" <fg=yellow>[dry-run] would drop</> {$partitionName}");
|
||||
} else {
|
||||
$this->dropPartition($partitionName);
|
||||
$this->line(" <fg=red>dropped</> {$partitionName}");
|
||||
}
|
||||
|
||||
$dropped++;
|
||||
$totalDropped++;
|
||||
}
|
||||
|
||||
if ($dropped === 0) {
|
||||
$this->line(" <fg=green>ok</> {$table}: all partitions within retention={$retention}mo");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("Dry-run complete: {$totalDropped} would be dropped, {$totalSkipped} skipped (unrecognised name).");
|
||||
} else {
|
||||
$this->info("Done: {$totalDropped} dropped, {$totalSkipped} skipped (unrecognised name).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает retention для таблицы из system_settings.
|
||||
* Возвращает null, если настройка отсутствует или небезопасна (0 / отрицательная).
|
||||
*/
|
||||
private function resolveRetention(string $table): ?int
|
||||
{
|
||||
$key = "partition_retention_months_{$table}";
|
||||
|
||||
$setting = SystemSetting::find($key);
|
||||
|
||||
if ($setting === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (int) $setting->value;
|
||||
|
||||
if ($value < 1) {
|
||||
// 0 или отрицательное — блокируем, не дропаем ничего
|
||||
$this->warn(" ! {$table}: retention value={$value} is invalid (<1), skipping");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит имя партиции вида <anything>_y<YYYY>_m<MM> и возвращает Carbon начала месяца.
|
||||
* Возвращает null, если имя не соответствует формату.
|
||||
*/
|
||||
private function parsePartitionMonth(string $partitionName): ?Carbon
|
||||
{
|
||||
// Pattern: ends with _yYYYY_mMM (e.g. auth_log_y2026_m05)
|
||||
if (! preg_match('/_y(\d{4})_m(\d{2})$/', $partitionName, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$year = (int) $m[1];
|
||||
$month = (int) $m[2];
|
||||
|
||||
if ($month < 1 || $month > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::create($year, $month, 1, 0, 0, 0)->startOfMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет партицию (DETACH + DROP TABLE).
|
||||
*
|
||||
* Безопасность: имя проверено регекспом в parsePartitionMonth
|
||||
* (только символы \w и _ — SQL injection невозможен).
|
||||
*/
|
||||
private function dropPartition(string $partitionName): void
|
||||
{
|
||||
DB::statement("DROP TABLE IF EXISTS {$partitionName}");
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,13 @@ class RemindersDispatchDue extends Command
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$now = Carbon::now();
|
||||
|
||||
// Берём список pending-reminders. Без RLS — admin-flow на serverside.
|
||||
// Для каждой устанавливаем app.current_tenant_id внутри транзакции.
|
||||
$pending = Reminder::query()
|
||||
// Cross-tenant gather via BYPASSRLS connection — on prod crm_app_user cannot
|
||||
// call current_setting('app.current_tenant_id') without a GUC set first.
|
||||
// pgsql_supplier (crm_supplier_worker, BYPASSRLS) is the canonical pattern
|
||||
// for SaaS-admin cron queries (precedent: IncidentsWatchFailures, Reset*).
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('reminders')
|
||||
->select(['id', 'tenant_id', 'deal_id', 'remind_at'])
|
||||
->where('is_sent', false)
|
||||
->whereNull('completed_at')
|
||||
->where('remind_at', '<=', $now)
|
||||
@@ -55,7 +59,7 @@ class RemindersDispatchDue extends Command
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Нет due-reminders.');
|
||||
|
||||
return self::SUCCESS;
|
||||
@@ -64,22 +68,26 @@ class RemindersDispatchDue extends Command
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($pending as $reminder) {
|
||||
foreach ($rows as $row) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
|
||||
$reminder->id,
|
||||
$reminder->tenant_id,
|
||||
$reminder->deal_id,
|
||||
$reminder->remind_at?->toIso8601String() ?? '-',
|
||||
$row->id,
|
||||
$row->tenant_id,
|
||||
$row->deal_id,
|
||||
$row->remind_at ?? '-',
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($reminder, $service): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $reminder->tenant_id);
|
||||
DB::transaction(function () use ($row, $service): void {
|
||||
// SET LOCAL scopes GUC to this transaction — PgBouncer-safe.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $row->tenant_id);
|
||||
// Fetch the full Eloquent model with tenant context active so
|
||||
// relations (user, etc.) work correctly inside NotificationService.
|
||||
$reminder = Reminder::query()->findOrFail((int) $row->id);
|
||||
$service->notifyReminder($reminder);
|
||||
$reminder->update([
|
||||
'is_sent' => true,
|
||||
@@ -87,10 +95,10 @@ class RemindersDispatchDue extends Command
|
||||
]);
|
||||
});
|
||||
$sent++;
|
||||
$this->info(" dispatched <fg=green>id={$reminder->id}</>");
|
||||
$this->info(" dispatched <fg=green>id={$row->id}</>");
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(" failed <fg=red>id={$reminder->id}</>: {$e->getMessage()}");
|
||||
$this->error(" failed <fg=red>id={$row->id}</>: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,11 @@ class ReportsCleanupExpired extends Command
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$jobs = ReportJob::query()
|
||||
// Cross-tenant gather via BYPASSRLS connection — crm_app_user on prod cannot
|
||||
// evaluate current_setting('app.current_tenant_id') without a GUC set.
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('report_jobs')
|
||||
->select(['id', 'tenant_id', 'file_path', 'expires_at'])
|
||||
->where('status', ReportJob::STATUS_DONE)
|
||||
->whereNotNull('file_path')
|
||||
->where('expires_at', '<', Carbon::now())
|
||||
@@ -51,36 +55,45 @@ class ReportsCleanupExpired extends Command
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($jobs->isEmpty()) {
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Нет expired report-files для удаления.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($jobs as $job) {
|
||||
foreach ($rows as $row) {
|
||||
$this->line(sprintf(
|
||||
'[%s] tenant=%d job=%d path=%s expired_at=%s',
|
||||
$dryRun ? 'DRY' : 'DEL',
|
||||
$job->tenant_id,
|
||||
$job->id,
|
||||
$job->file_path,
|
||||
$job->expires_at?->toIso8601String() ?? '?',
|
||||
$row->tenant_id,
|
||||
$row->id,
|
||||
$row->file_path,
|
||||
$row->expires_at ?? '?',
|
||||
));
|
||||
|
||||
if (! $dryRun) {
|
||||
Storage::disk('local')->delete($job->file_path);
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'deleted',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'report_cleanup_expired_'.$job->id,
|
||||
tenantId: $job->tenant_id,
|
||||
actorTenantUserId: null,
|
||||
actorAdminUserId: null,
|
||||
ip: null,
|
||||
);
|
||||
$job->update(['file_path' => null]);
|
||||
Storage::disk('local')->delete($row->file_path);
|
||||
|
||||
// Both writes go through pgsql_supplier (BYPASSRLS) — this is a
|
||||
// SaaS-admin cron, not a per-user action, so no tenant GUC is
|
||||
// required. Same pattern as IncidentsWatchFailures, Reset*.
|
||||
DB::connection('pgsql_supplier')->table('pd_processing_log')->insert([
|
||||
'tenant_id' => $row->tenant_id,
|
||||
'subject_type' => 'lead',
|
||||
'subject_id' => null,
|
||||
'action' => 'deleted',
|
||||
'purpose' => 'report_cleanup_expired_'.$row->id,
|
||||
'actor_tenant_user_id' => null,
|
||||
'actor_admin_user_id' => null,
|
||||
'ip_address' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
DB::connection('pgsql_supplier')
|
||||
->table('report_jobs')
|
||||
->where('id', $row->id)
|
||||
->update(['file_path' => null]);
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\SchedulerHeartbeatMissingMail;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Hole #6: проверяет пульс всех зарегистрированных cron-задач.
|
||||
*
|
||||
* Критерии алерта (для каждой команды в scheduler_heartbeats):
|
||||
* 1. last_run_at IS NULL ИЛИ отсутствует > 2× ожидаемого интервала.
|
||||
* 2. consecutive_failures >= 3.
|
||||
*
|
||||
* При обнаружении:
|
||||
* • Создаёт инцидент в incidents_log (type=other, severity=high).
|
||||
* • Отправляет SchedulerHeartbeatMissingMail на kdv1@bk.ru.
|
||||
* • Дедупликация: не создаёт повторный инцидент если открытый уже есть
|
||||
* с той же командой в последние 60 минут.
|
||||
*
|
||||
* Запускается hourly через routes/console.php.
|
||||
*/
|
||||
final class SchedulerCheckHeartbeats extends Command
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
private const ALERT_EMAIL = 'kdv1@bk.ru';
|
||||
|
||||
private const DEDUP_MINUTES = 60;
|
||||
|
||||
private const FAILURE_THRESHOLD = 3;
|
||||
|
||||
protected $signature = 'scheduler:check-heartbeats';
|
||||
|
||||
protected $description = 'Проверяет пульс cron-задач и алертит при пропавшем пульсе или повторных ошибках';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$intervals = SchedulerHeartbeatTracker::EXPECTED_INTERVALS;
|
||||
$db = DB::connection(self::DB_CONNECTION);
|
||||
$now = Carbon::now();
|
||||
$dedupAt = $now->copy()->subMinutes(self::DEDUP_MINUTES);
|
||||
|
||||
// Получаем adminId для FK incidents_log
|
||||
$adminId = $db->table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
// Паттерн VerifyAuditChains (hole #1): warn + SUCCESS, не FAILURE.
|
||||
// FAILURE здесь = бесконечный цикл self-alert (consecutive_failures растёт,
|
||||
// watcher пытается алертить, снова FAILURE, инцидент не создаётся).
|
||||
$this->warn('No active saas_admin_users — alerts disabled (warn-only mode).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Загружаем все существующие heartbeats
|
||||
$rows = $db->table('scheduler_heartbeats')
|
||||
->get()
|
||||
->keyBy('command_name');
|
||||
|
||||
$alerted = 0;
|
||||
|
||||
foreach ($intervals as $commandName => $expectedMinutes) {
|
||||
$row = $rows->get($commandName);
|
||||
|
||||
// Проверка 1: пропавший пульс (нет строки вообще или last_run_at старше 2× интервала)
|
||||
$heartbeatMissing = false;
|
||||
if ($row === null) {
|
||||
$heartbeatMissing = true;
|
||||
$reason = "Команда '{$commandName}' не имеет ни одной записи heartbeat.";
|
||||
} elseif ($row->last_run_at === null) {
|
||||
$heartbeatMissing = true;
|
||||
$reason = "Команда '{$commandName}' никогда не запускалась.";
|
||||
} else {
|
||||
$lastRun = Carbon::parse($row->last_run_at);
|
||||
$ageMinutes = $lastRun->diffInMinutes($now);
|
||||
$threshold = $expectedMinutes * 2;
|
||||
|
||||
if ($ageMinutes > $threshold) {
|
||||
$heartbeatMissing = true;
|
||||
$reason = "Команда '{$commandName}' не запускалась {$ageMinutes} мин. "
|
||||
."(ожидаемый интервал: {$expectedMinutes} мин, порог: {$threshold} мин).";
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка 2: consecutive_failures >= threshold
|
||||
$consecutiveFailures = $row !== null ? (int) $row->consecutive_failures : 0;
|
||||
$failureSpike = $consecutiveFailures >= self::FAILURE_THRESHOLD;
|
||||
|
||||
if (! $heartbeatMissing && ! $failureSpike) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($reason)) {
|
||||
$reason = "Команда '{$commandName}' завершилась с ошибкой {$consecutiveFailures} раз подряд.";
|
||||
} elseif ($failureSpike) {
|
||||
$reason .= " Плюс {$consecutiveFailures} последовательных ошибок.";
|
||||
}
|
||||
|
||||
$lastError = $row?->last_error;
|
||||
|
||||
// Дедупликация
|
||||
$summary = "Scheduler heartbeat: {$commandName} — {$reason}";
|
||||
$sigPrefix = substr("Scheduler heartbeat: {$commandName}", 0, 80);
|
||||
|
||||
$alreadyOpen = $db->table('incidents_log')
|
||||
->where('summary', 'like', '%'.addcslashes($sigPrefix, '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupAt)
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
$this->line("Dedup: {$commandName}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Создаём инцидент
|
||||
$db->table('incidents_log')->insert([
|
||||
'type' => 'other',
|
||||
'severity' => 'high',
|
||||
'summary' => $summary,
|
||||
'root_cause' => null,
|
||||
'started_at' => $now,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Отправляем email
|
||||
Mail::to(self::ALERT_EMAIL)->send(
|
||||
new SchedulerHeartbeatMissingMail(
|
||||
commandName: $commandName,
|
||||
reason: $reason,
|
||||
lastError: $lastError,
|
||||
consecutiveFailures: $consecutiveFailures,
|
||||
)
|
||||
);
|
||||
|
||||
$this->warn("Alert: {$commandName} — {$reason}");
|
||||
$alerted++;
|
||||
unset($reason);
|
||||
}
|
||||
|
||||
$this->info("Done. {$alerted} alert(s) created.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\AuditChainBreachMail;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Проверяет целостность SHA-256 hash-chain во всех 6 audit-таблицах.
|
||||
*
|
||||
* Алгоритм на стороне PostgreSQL (не PHP) — чтобы воспроизвести ровно ту же
|
||||
* сериализацию ROW::text, что использует триггер audit_chain_hash():
|
||||
*
|
||||
* digest(COALESCE(prev_log_hash,''::bytea) || ROW(col1,...,NULL::bytea,...col_n)::text::bytea, 'sha256')
|
||||
*
|
||||
* где NULL::bytea — позиция log_hash (она была NULL в момент срабатывания
|
||||
* BEFORE INSERT триггера). Список столбцов в порядке их ordinal_position
|
||||
* из information_schema жёстко закодирован для каждой таблицы.
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────────
|
||||
* ВАЖНО: per-partition scan (hole #2 adaptation).
|
||||
*
|
||||
* После перевода таблиц на RANGE-партиционирование (v8.31) каждая партиция
|
||||
* содержит строки одного месяца. Триггер audit_chain_hash() при INSERT в
|
||||
* партицию видит строки только ЭТОЙ партиции (TG_TABLE_NAME = partition name,
|
||||
* SELECT LAG по partition → prev — последняя запись той же партиции).
|
||||
*
|
||||
* Поэтому валидатор проверяет hash-chain отдельно для каждой партиции:
|
||||
* 1. Получает список партиций через pg_inherits + pg_class.
|
||||
* 2. Для каждой партиции выполняет checkPartition().
|
||||
* 3. Несоответствие в ЛЮБОЙ партиции → инцидент с указанием partition_name.
|
||||
*
|
||||
* Пустые партиции (без строк) — OK, chain пустая = intact.
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────────
|
||||
* ВАЖНО: per-scope RLS partitioning.
|
||||
*
|
||||
* Триггер audit_chain_hash() делает:
|
||||
* SELECT log_hash FROM <table> ORDER BY id DESC LIMIT 1
|
||||
* Этот SELECT выполняется под ролью вставляющей сессии и подпадает под RLS.
|
||||
*
|
||||
* После партиционирования SELECT работает внутри текущей партиции — TG_TABLE_NAME.
|
||||
* RLS-scope воспроизводится так же, как до партиционирования, но область
|
||||
* видимости ограничена одной партицией → per-partition per-RLS-scope цепочка.
|
||||
*
|
||||
* Валидатор воспроизводит это через PARTITION BY RLS-scope ВНУТРИ каждой
|
||||
* partition-таблицы (те же partition_clause что раньше).
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* При разрыве: создаёт incidents_log (type='other', severity='high', через
|
||||
* pgsql_supplier BYPASSRLS), дедупликация 24ч, email на kdv1@bk.ru.
|
||||
* Возвращает self::FAILURE при ЛЮБОМ разрыве — независимо от успеха записи
|
||||
* инцидента (инцидент-запись best-effort, не влияет на exit code).
|
||||
*
|
||||
* Запускается daily 04:00 (routes/console.php).
|
||||
*
|
||||
* Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
|
||||
* docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md §A.4
|
||||
* Паттерн: IncidentsWatchFailures + SharesSupplierPdo.
|
||||
*/
|
||||
class VerifyAuditChains extends Command
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/**
|
||||
* Monitoring email для критичных алертов audit-целостности.
|
||||
*/
|
||||
private const MONITORING_EMAIL = 'kdv1@bk.ru';
|
||||
|
||||
/**
|
||||
* Дедупликация инцидентов: не создавать повторный инцидент по той же таблице
|
||||
* если прошло менее DEDUP_HOURS часов.
|
||||
*/
|
||||
private const DEDUP_HOURS = 24;
|
||||
|
||||
protected $signature = 'audit:verify-chains';
|
||||
|
||||
protected $description = 'Проверяет целостность SHA-256 hash-chain в 6 audit-таблицах (per-partition)';
|
||||
|
||||
/**
|
||||
* Конфигурация таблиц: имя таблицы → [columns, partition_clause].
|
||||
*
|
||||
* columns: список столбцов строго в порядке ordinal_position из db/schema.sql.
|
||||
* Специальное значение '__log_hash__' — маркер позиции log_hash → NULL::bytea.
|
||||
*
|
||||
* partition_clause: SQL-фрагмент для OVER (PARTITION BY … ORDER BY id),
|
||||
* воспроизводящий RLS-scope триггера внутри одной партиции.
|
||||
* Пустая строка = глобальная цепочка внутри партиции.
|
||||
*
|
||||
* @var array<string, array{columns: list<string>, partition: string}>
|
||||
*/
|
||||
private const TABLE_CONFIG = [
|
||||
// auth_log:
|
||||
// RLS: actor_type='tenant_user' AND tenant_id = current_setting(...)
|
||||
// Tenant-сессия видит только (actor_type='tenant_user', tenant_id=N).
|
||||
// saas_admin-сессия BYPASSRLS — видит всё.
|
||||
// Partition (actor_type, tenant_id) воспроизводит оба случая:
|
||||
// каждая пара образует независимую цепочку.
|
||||
'auth_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'actor_type',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'saas_admin_user_id',
|
||||
'email',
|
||||
'event',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'failure_reason',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
// global chain: auth_log пишется при ЛОГИНЕ под BYPASSRLS-роль
|
||||
// (tenant ещё не установлен — пользователь не аутентифицирован),
|
||||
// поэтому триггерный prev-SELECT видит ВСЕ строки → цепочка глобальная
|
||||
// внутри данной партиции (эмпирически подтверждено прод-smoke).
|
||||
'partition' => '',
|
||||
],
|
||||
|
||||
// activity_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'activity_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'deal_id',
|
||||
'event',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'context',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// tenant_operations_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'tenant_operations_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'event',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// balance_transactions:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'balance_transactions' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'type',
|
||||
'amount_rub',
|
||||
'amount_leads',
|
||||
'balance_rub_after',
|
||||
'balance_leads_after',
|
||||
'description',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'user_id',
|
||||
'admin_user_id',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// pd_processing_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'pd_processing_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'action',
|
||||
'purpose',
|
||||
'actor_tenant_user_id',
|
||||
'actor_admin_user_id',
|
||||
'ip_address',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// saas_admin_audit_log:
|
||||
// Нет RLS-политики для tenant-ролей (REVOKE ALL FROM crm_app_user).
|
||||
// Вставляет только crm_admin_user (BYPASSRLS) — триггер's SELECT
|
||||
// видит ВСЕ строки партиции → цепочка глобальная внутри партиции.
|
||||
// Partition: нет (пустая строка = ORDER BY id без PARTITION BY).
|
||||
'saas_admin_audit_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'admin_user_id',
|
||||
'action',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target_tenant_id',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'reason',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'requires_approval',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => '', // global chain within partition — inserting role is BYPASSRLS
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$anyBreach = false;
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach (self::TABLE_CONFIG as $table => $config) {
|
||||
// Get all partitions for this table via pg_inherits.
|
||||
$partitions = $this->listPartitions($table);
|
||||
|
||||
if (empty($partitions)) {
|
||||
// Table not yet partitioned or no partitions — check parent directly (fallback).
|
||||
$partitions = [$table];
|
||||
}
|
||||
|
||||
foreach ($partitions as $partitionName) {
|
||||
$breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']);
|
||||
|
||||
if (empty($breaches)) {
|
||||
$this->line(" ✓ {$partitionName}: chain intact");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$anyBreach = true;
|
||||
$firstId = $breaches[0]->id;
|
||||
$count = count($breaches);
|
||||
|
||||
$this->error(" ✗ {$partitionName}: {$count} mismatch(es), first broken id={$firstId}");
|
||||
|
||||
// Incident write is best-effort: never let it suppress the breach signal.
|
||||
try {
|
||||
$this->recordIncident($table, $partitionName, $firstId, $count, $now);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" Incident write failed for {$partitionName}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$this->sendAlert($table, $partitionName, $firstId, $count);
|
||||
}
|
||||
}
|
||||
|
||||
// Exit FAILURE on ANY breach regardless of incident-write success.
|
||||
if ($anyBreach) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('All audit chains intact.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список дочерних партиций таблицы через pg_inherits.
|
||||
* Возвращает пустой массив если таблица не партиционирована или партиций нет.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function listPartitions(string $table): array
|
||||
{
|
||||
$rows = DB::connection(self::DB_CONNECTION)->select(
|
||||
'SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = ?
|
||||
ORDER BY c.relname',
|
||||
[$table],
|
||||
);
|
||||
|
||||
return array_map(fn ($r) => $r->relname, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет hash-chain одной партиции (или таблицы) через SQL на стороне PostgreSQL.
|
||||
*
|
||||
* Возвращает список строк, у которых stored log_hash ≠ recomputed hash.
|
||||
*
|
||||
* SQL-логика:
|
||||
* 1. Берёт все строки партиции.
|
||||
* 2. Через LAG(log_hash) OVER (<partition> ORDER BY id) получает prev_hash
|
||||
* каждой строки в пределах её RLS-scope (partition).
|
||||
* 3. Пересчитывает: digest(COALESCE(prev_hash,''::bytea) || ROW(...)::text::bytea, 'sha256')
|
||||
* где ROW(...) имеет NULL::bytea на позиции log_hash.
|
||||
* 4. Возвращает строки, где stored IS DISTINCT FROM recomputed.
|
||||
*
|
||||
* @param list<string> $columns
|
||||
* @return list<object>
|
||||
*/
|
||||
private function checkPartition(string $partitionName, array $columns, string $partition): array
|
||||
{
|
||||
$rowExpr = $this->buildRowExpression($columns);
|
||||
|
||||
// Build OVER clause: with or without PARTITION BY depending on table's RLS scope.
|
||||
$overClause = $partition !== ''
|
||||
? "({$partition} ORDER BY id)"
|
||||
: '(ORDER BY id)';
|
||||
|
||||
$sql = <<<SQL
|
||||
WITH ordered AS (
|
||||
SELECT
|
||||
id,
|
||||
log_hash AS stored_hash,
|
||||
LAG(log_hash) OVER {$overClause} AS prev_hash
|
||||
FROM {$partitionName}
|
||||
)
|
||||
SELECT
|
||||
o.id,
|
||||
o.stored_hash,
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT {$rowExpr}::text::bytea FROM {$partitionName} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
) AS recomputed_hash
|
||||
FROM ordered o
|
||||
WHERE o.stored_hash IS DISTINCT FROM
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT {$rowExpr}::text::bytea FROM {$partitionName} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
)
|
||||
ORDER BY o.id
|
||||
SQL;
|
||||
|
||||
/** @var list<object> $results */
|
||||
$results = DB::connection(self::DB_CONNECTION)
|
||||
->select($sql);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит SQL-выражение ROW(col1, col2, ..., NULL::bytea, ..., coln)
|
||||
* с NULL::bytea на месте log_hash.
|
||||
*
|
||||
* Пример для auth_log:
|
||||
* ROW(t.id, t.actor_type, t.tenant_id, ..., NULL::bytea, t.created_at)
|
||||
*
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function buildRowExpression(array $columns): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($columns as $col) {
|
||||
$parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}";
|
||||
}
|
||||
|
||||
return 'ROW('.implode(', ', $parts).')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет запись в incidents_log (через pgsql_supplier BYPASSRLS).
|
||||
* Дедупликация: не создаёт повторный инцидент для той же таблицы,
|
||||
* если за последние DEDUP_HOURS часов уже есть открытый инцидент.
|
||||
*
|
||||
* Вызывается внутри try/catch в handle() — исключение не подавляет
|
||||
* breach-сигнал (handle() всё равно вернёт self::FAILURE).
|
||||
*
|
||||
* @param string $table Имя родительской таблицы (для дедупликации)
|
||||
* @param string $partitionName Имя конкретной партиции где обнаружен разрыв
|
||||
*/
|
||||
private function recordIncident(
|
||||
string $table,
|
||||
string $partitionName,
|
||||
int $firstBrokenId,
|
||||
int $count,
|
||||
Carbon $now
|
||||
): void {
|
||||
$dedupSince = $now->copy()->subHours(self::DEDUP_HOURS);
|
||||
|
||||
$alreadyOpen = DB::connection(self::DB_CONNECTION)
|
||||
->table('incidents_log')
|
||||
->where('type', 'other')
|
||||
->where('severity', 'high')
|
||||
->where('summary', 'like', '%chain%'.addcslashes($table, '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupSince)
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
$this->line(" Skipping incident (dedup): {$partitionName}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Для NOT NULL FK created_by_admin_id берём первого активного SaaS-admin.
|
||||
// Если нет активных admins — пишем предупреждение, но НЕ пропускаем:
|
||||
// бросаем исключение, чтобы caller (try/catch в handle()) его поймал
|
||||
// и залогировал. Breach-сигнал (FAILURE exit code) уже установлен выше.
|
||||
$adminId = DB::connection(self::DB_CONNECTION)
|
||||
->table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
$this->warn(" No active saas_admin_users — incident not recorded for {$partitionName}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
|
||||
'type' => 'other',
|
||||
'severity' => 'high',
|
||||
'summary' => "Автоматически: разрыв hash-chain в партиции {$partitionName} (таблица {$table}). "
|
||||
."Первый сломанный id={$firstBrokenId}, всего несовпадений={$count}. "
|
||||
.'Возможен tampering (UPDATE/DELETE в обход триггеров).',
|
||||
'root_cause' => null,
|
||||
'started_at' => $now,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->warn(" Incident recorded for {$partitionName} (first broken id={$firstBrokenId})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет email-алёрт на monitoring email.
|
||||
*/
|
||||
private function sendAlert(string $table, string $partitionName, int $firstBrokenId, int $count): void
|
||||
{
|
||||
try {
|
||||
Mail::to(self::MONITORING_EMAIL)
|
||||
->send(new AuditChainBreachMail($table, $firstBrokenId, $count, $partitionName));
|
||||
} catch (\Throwable $e) {
|
||||
// Не ломаем команду если почта недоступна — инцидент уже записан
|
||||
$this->warn(" Email failed: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Pd\PdErasureService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* SaaS-admin: управление обращениями субъектов ПДн (152-ФЗ).
|
||||
*
|
||||
* Saas-уровневый endpoint (НЕ tenant-aware), под middleware('saas-admin').
|
||||
* Production: middleware('auth:saas-admin') — реализуется после Б-1 + DO-4.
|
||||
*
|
||||
* Маршруты:
|
||||
* GET /api/admin/pd-subject-requests → index
|
||||
* POST /api/admin/pd-subject-requests → store
|
||||
* GET /api/admin/pd-subject-requests/{id} → show
|
||||
* POST /api/admin/pd-subject-requests/{id}/erase → executeErasure
|
||||
*/
|
||||
class AdminPdSubjectRequestsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
public function __construct(private readonly PdErasureService $erasureService) {}
|
||||
|
||||
/**
|
||||
* GET /api/admin/pd-subject-requests
|
||||
*
|
||||
* Список обращений с пагинацией. Фильтры: status, request_type.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$status = (string) $request->query('status', '');
|
||||
$requestType = (string) $request->query('request_type', '');
|
||||
$limit = max(1, min(200, (int) $request->query('limit', '50')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
|
||||
$query = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->orderByDesc('received_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($requestType !== '') {
|
||||
$query->where('request_type', $requestType);
|
||||
}
|
||||
|
||||
$total = (clone $query)->count('id');
|
||||
$rows = $query->limit($limit)->offset($offset)->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows->map(fn ($r) => $this->formatRow($r)),
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/pd-subject-requests/{id}
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
return response()->json(['message' => 'Обращение не найдено.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['data' => $this->formatRow($row)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/pd-subject-requests
|
||||
*
|
||||
* Создать новое обращение субъекта. Deadline автоматически +30 дней
|
||||
* через PostgreSQL-триггер trg_pd_subject_requests_deadline.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject_email' => ['nullable', 'email', 'max:255'],
|
||||
'subject_phone' => ['nullable', 'string', 'max:20'],
|
||||
'subject_full_name' => ['nullable', 'string', 'max:255'],
|
||||
'request_type' => ['required', Rule::in(['access', 'rectification', 'deletion', 'objection'])],
|
||||
'description' => ['nullable', 'string', 'max:4096'],
|
||||
'tenant_id' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
// Минимум один идентификатор субъекта
|
||||
if (empty($validated['subject_email']) && empty($validated['subject_phone'])) {
|
||||
return response()->json([
|
||||
'message' => 'Укажите email или телефон субъекта.',
|
||||
'errors' => ['subject_email' => ['Необходимо email или телефон.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
// NB: deadline_at заполняется триггером trg_pd_subject_requests_deadline
|
||||
// (received_at + 30 дней). Передаём placeholder — триггер перезапишет.
|
||||
$id = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->insertGetId([
|
||||
'received_at' => $now,
|
||||
'subject_email' => $validated['subject_email'] ?? null,
|
||||
'subject_phone' => $validated['subject_phone'] ?? null,
|
||||
'subject_full_name' => $validated['subject_full_name'] ?? null,
|
||||
'request_type' => $validated['request_type'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'status' => 'received',
|
||||
'tenant_id' => $validated['tenant_id'] ?? null,
|
||||
'processing_restricted' => false,
|
||||
// deadline_at: trigger перезапишет, но NOT NULL требует значения
|
||||
'deadline_at' => $now->addDays(30),
|
||||
]);
|
||||
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
return response()->json(['data' => $this->formatRow($row)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/pd-subject-requests/{id}/erase
|
||||
*
|
||||
* Выполнить анонимизацию ПДн для обращения с request_type='deletion'.
|
||||
* Возвращает counts анонимизированных записей.
|
||||
*/
|
||||
public function executeErasure(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
return response()->json(['message' => 'Обращение не найдено.'], 404);
|
||||
}
|
||||
|
||||
if ($row->request_type !== 'deletion') {
|
||||
return response()->json([
|
||||
'message' => 'Анонимизация доступна только для обращений типа "deletion".',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($row->status === 'completed') {
|
||||
return response()->json([
|
||||
'message' => 'Обращение уже выполнено.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (empty($row->subject_email) && empty($row->subject_phone)) {
|
||||
return response()->json([
|
||||
'message' => 'В обращении не указан email или телефон субъекта.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$adminId = $this->resolveAdminUserId(
|
||||
$request,
|
||||
'pd-erasure-stub@system.local',
|
||||
'PD Erasure System',
|
||||
);
|
||||
|
||||
$counts = $this->erasureService->eraseSubject(
|
||||
email: $row->subject_email ?: null,
|
||||
phone: $row->subject_phone ?: null,
|
||||
tenantId: $row->tenant_id !== null ? (int) $row->tenant_id : null,
|
||||
actorAdminId: $adminId,
|
||||
requestId: (string) $id,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Анонимизация выполнена.',
|
||||
'counts' => $counts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматировать строку pd_subject_requests в массив для API.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatRow(object $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'received_at' => $row->received_at !== null
|
||||
? CarbonImmutable::parse($row->received_at)->toIso8601String() : null,
|
||||
'subject_email' => $row->subject_email,
|
||||
'subject_phone' => $row->subject_phone,
|
||||
'subject_full_name' => $row->subject_full_name,
|
||||
'request_type' => $row->request_type,
|
||||
'description' => $row->description,
|
||||
'status' => $row->status,
|
||||
'tenant_id' => $row->tenant_id !== null ? (int) $row->tenant_id : null,
|
||||
'assigned_admin_id' => $row->assigned_admin_id !== null
|
||||
? (int) $row->assigned_admin_id : null,
|
||||
'response_text' => $row->response_text,
|
||||
'deadline_at' => $row->deadline_at !== null
|
||||
? CarbonImmutable::parse($row->deadline_at)->toIso8601String() : null,
|
||||
'completed_at' => $row->completed_at !== null
|
||||
? CarbonImmutable::parse($row->completed_at)->toIso8601String() : null,
|
||||
'processing_restricted' => (bool) $row->processing_restricted,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,7 @@ class ReportJobController extends Controller
|
||||
|
||||
// Sync queue на dev — Job выполняется немедленно.
|
||||
// На prod queue.driver=redis/database — async через worker.
|
||||
GenerateReportJob::dispatch($job->id);
|
||||
GenerateReportJob::dispatch($job->id, (int) $user->tenant_id);
|
||||
|
||||
return response()->json([
|
||||
'job' => $this->toResource($job->fresh()),
|
||||
@@ -254,7 +254,7 @@ class ReportJobController extends Controller
|
||||
'status' => ReportJob::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
GenerateReportJob::dispatch($newJob->id);
|
||||
GenerateReportJob::dispatch($newJob->id, (int) $user->tenant_id);
|
||||
|
||||
return response()->json([
|
||||
'job' => $this->toResource($newJob->fresh()),
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
@@ -37,65 +38,73 @@ class GenerateReportJob implements ShouldQueue
|
||||
|
||||
public function __construct(
|
||||
public readonly int $reportJobId,
|
||||
public readonly int $tenantId,
|
||||
) {}
|
||||
|
||||
public function handle(ReportGeneratorRegistry $registry): void
|
||||
{
|
||||
$job = ReportJob::query()->find($this->reportJobId);
|
||||
if ($job === null) {
|
||||
Log::warning('GenerateReportJob: report_job not found', ['id' => $this->reportJobId]);
|
||||
// SET LOCAL inside a transaction establishes the tenant GUC for the
|
||||
// duration of this block — required by RLS on report_jobs for
|
||||
// crm_app_user (non-BYPASSRLS) on production.
|
||||
DB::transaction(function () use ($registry): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($job->status, ReportJob::ACTIVE_STATUSES, true)) {
|
||||
// Уже terminal — повторный dispatch (например, Horizon retry) пропускаем.
|
||||
return;
|
||||
}
|
||||
|
||||
$job->update(['status' => ReportJob::STATUS_PROCESSING]);
|
||||
|
||||
$startedAt = microtime(true);
|
||||
try {
|
||||
$params = $job->parameters ?? [];
|
||||
$format = (string) ($params['format'] ?? 'csv');
|
||||
|
||||
if (! $registry->isSupported($job->type, $format)) {
|
||||
$this->markFailed($job, "Неподдерживаемая комбинация: {$job->type}/{$format}", $startedAt);
|
||||
$job = ReportJob::query()->find($this->reportJobId);
|
||||
if ($job === null) {
|
||||
Log::warning('GenerateReportJob: report_job not found', ['id' => $this->reportJobId]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $registry->provider($job->type);
|
||||
$formatter = $registry->formatter($format);
|
||||
if (! in_array($job->status, ReportJob::ACTIVE_STATUSES, true)) {
|
||||
// Уже terminal — повторный dispatch (например, Horizon retry) пропускаем.
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = $provider->headers();
|
||||
$rows = $provider->rows($job);
|
||||
$content = $formatter->format($headers, $rows);
|
||||
$job->update(['status' => ReportJob::STATUS_PROCESSING]);
|
||||
|
||||
$relativePath = sprintf(
|
||||
'reports/%d/%d.%s',
|
||||
$job->tenant_id,
|
||||
$job->id,
|
||||
$formatter->fileExtension()
|
||||
);
|
||||
Storage::disk('local')->put($relativePath, $content);
|
||||
$startedAt = microtime(true);
|
||||
try {
|
||||
$params = $job->parameters ?? [];
|
||||
$format = (string) ($params['format'] ?? 'csv');
|
||||
|
||||
$job->update([
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => $relativePath,
|
||||
'file_size' => strlen($content),
|
||||
'generation_seconds' => max(1, (int) round(microtime(true) - $startedAt)),
|
||||
'finished_at' => Carbon::now(),
|
||||
'expires_at' => Carbon::now()->addDays(30),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($job, mb_substr($e->getMessage(), 0, 1000), $startedAt);
|
||||
Log::error('GenerateReportJob failed', [
|
||||
'id' => $this->reportJobId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
if (! $registry->isSupported($job->type, $format)) {
|
||||
$this->markFailed($job, "Неподдерживаемая комбинация: {$job->type}/{$format}", $startedAt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $registry->provider($job->type);
|
||||
$formatter = $registry->formatter($format);
|
||||
|
||||
$headers = $provider->headers();
|
||||
$rows = $provider->rows($job);
|
||||
$content = $formatter->format($headers, $rows);
|
||||
|
||||
$relativePath = sprintf(
|
||||
'reports/%d/%d.%s',
|
||||
$job->tenant_id,
|
||||
$job->id,
|
||||
$formatter->fileExtension()
|
||||
);
|
||||
Storage::disk('local')->put($relativePath, $content);
|
||||
|
||||
$job->update([
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => $relativePath,
|
||||
'file_size' => strlen($content),
|
||||
'generation_seconds' => max(1, (int) round(microtime(true) - $startedAt)),
|
||||
'finished_at' => Carbon::now(),
|
||||
'expires_at' => Carbon::now()->addDays(30),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($job, mb_substr($e->getMessage(), 0, 1000), $startedAt);
|
||||
Log::error('GenerateReportJob failed', [
|
||||
'id' => $this->reportJobId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function markFailed(ReportJob $job, string $message, float $startedAt): void
|
||||
|
||||
@@ -370,7 +370,11 @@ class ProcessWebhookJob implements ShouldQueue
|
||||
*/
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
DB::table('failed_webhook_jobs')->insert([
|
||||
// failed_webhook_jobs is an RLS-protected table. On production crm_app_user
|
||||
// (non-BYPASSRLS) there is no app.current_tenant_id GUC in the failed()
|
||||
// callback context. Use pgsql_supplier (crm_supplier_worker, BYPASSRLS) —
|
||||
// same pattern as RouteSupplierLeadJob::failed().
|
||||
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'webhook_log_id' => $this->webhookLogId,
|
||||
'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE),
|
||||
|
||||
@@ -129,7 +129,9 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
foreach ($missing as $row) {
|
||||
$platform = $this->extractPlatform((string) $row['project']);
|
||||
if ($platform === null) {
|
||||
Log::warning('csv_reconcile.unparseable_project_skipped', [
|
||||
// Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
|
||||
// Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
|
||||
Log::info('csv_reconcile.unparseable_project_skipped', [
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -189,10 +189,16 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Platforms skipped for a transient reason (not escalation/defer) — non-empty at the
|
||||
// end (with an active group) means the supplier set is incomplete → throw to retry.
|
||||
$retryWorthy = [];
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
$idMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
$createResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
$idMap = $createResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $createResult['failed']);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
@@ -233,7 +239,9 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreatedIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
$deadResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
$recreatedIdMap = $deadResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $deadResult['failed']);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
@@ -248,7 +256,9 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
$missingResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
$missingIdMap = $missingResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $missingResult['failed']);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
@@ -348,6 +358,21 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$project->{$column} = $sp->id;
|
||||
}
|
||||
$project->save();
|
||||
|
||||
// Atomicity guard: the 3 platforms are created by 3 sequential supplier calls. If one
|
||||
// failed for a TRANSIENT reason (network/timeout/5xx/id-not-found), the others are
|
||||
// already persisted above (progress kept) — but the supplier set is incomplete and the
|
||||
// group under-orders ~1/N. Throw so Laravel retries (backoff 15/60/300s); on retry the
|
||||
// partial-set recovery branch fills the missing platform — closing the gap in minutes
|
||||
// instead of waiting for the nightly batch. Escalation/window-defer are NOT here (they
|
||||
// have their own recovery), so they never trigger a retry.
|
||||
if ($retryWorthy !== [] && $groupActive) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'SyncSupplierProjectJob: project %d incomplete platform set (transient miss: %s) — retrying for partial-set recovery',
|
||||
$project->id,
|
||||
implode(',', array_values(array_unique($retryWorthy))),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -428,7 +453,11 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
*
|
||||
* @param array<string, int> $shares [platform => лимит площадки]
|
||||
* @param list<string> $platformsToCreate
|
||||
* @return array<string, int> [platform => external_id] для успешно созданных
|
||||
* @return array{ids: array<string, int>, failed: list<string>}
|
||||
* ids — [platform => external_id] для успешно созданных;
|
||||
* failed — площадки, пропущенные по TRANSIENT-причине (сеть/таймаут/id-not-found),
|
||||
* НЕ из-за escalation/window-defer (у тех свой механизм восстановления).
|
||||
* Непустой failed → handleOnline бросит retry-исключение.
|
||||
*/
|
||||
private function createPerPlatform(
|
||||
SupplierPortalClient $client,
|
||||
@@ -441,6 +470,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
array $platformsToCreate,
|
||||
): array {
|
||||
$idMap = [];
|
||||
$legitimateSkips = []; // escalation / window-defer — НЕ retry-worthy
|
||||
|
||||
foreach ($platformsToCreate as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
@@ -460,13 +490,17 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$result = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
|
||||
$legitimateSkips[] = $platform;
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
|
||||
$legitimateSkips[] = $platform;
|
||||
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
// Transient (network/timeout/portal 5xx). NOT added to legitimateSkips →
|
||||
// remains in `failed` → handleOnline throws → Laravel retry re-runs.
|
||||
Log::warning("SyncSupplierProjectJob: online per-platform save failed for project {$project->id} {$platform} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
continue;
|
||||
@@ -475,9 +509,13 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
if (isset($result[$platform])) {
|
||||
$idMap[$platform] = $result[$platform];
|
||||
}
|
||||
// else: save returned no id for this platform (id-not-found in listProjects) —
|
||||
// treat as transient: not in idMap, not in legitimateSkips → falls into `failed`.
|
||||
}
|
||||
|
||||
return $idMap;
|
||||
$failed = array_values(array_diff($platformsToCreate, array_keys($idMap), $legitimateSkips));
|
||||
|
||||
return ['ids' => $idMap, 'failed' => $failed];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Уведомление о разрыве hash-chain в audit-таблице.
|
||||
*
|
||||
* Триггер: команда audit:verify-chains обнаружила несовпадение
|
||||
* stored vs recomputed SHA-256 hash — признак tampering.
|
||||
*
|
||||
* Отправляется на kdv1@bk.ru (monitoring email).
|
||||
*/
|
||||
final class AuditChainBreachMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $tableName,
|
||||
public readonly int $firstBrokenId,
|
||||
public readonly int $mismatchCount,
|
||||
public readonly ?string $partitionName = null, // v8.31: partition where breach was detected
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->partitionName !== null && $this->partitionName !== $this->tableName
|
||||
? "[Лидерра CRITICAL] Разрыв hash-chain в {$this->partitionName}"
|
||||
: "[Лидерра CRITICAL] Разрыв hash-chain в {$this->tableName}";
|
||||
|
||||
return new Envelope(subject: $subject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.audit_chain_breach_text',
|
||||
with: [
|
||||
'tableName' => $this->tableName,
|
||||
'partitionName' => $this->partitionName ?? $this->tableName,
|
||||
'firstBrokenId' => $this->firstBrokenId,
|
||||
'mismatchCount' => $this->mismatchCount,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Уведомление об автоматически обнаруженном инциденте.
|
||||
*
|
||||
* Отправляется только для severity=high командой incidents:watch-failures.
|
||||
* Subject: [Лидерра HIGH] Incident: {summary first 100}.
|
||||
*/
|
||||
final class IncidentDetectedMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $summary,
|
||||
public readonly string $severity,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subjectSnippet = mb_substr($this->summary, 0, 100);
|
||||
|
||||
return new Envelope(
|
||||
subject: "[Лидерра HIGH] Incident: {$subjectSnippet}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.incident_detected_text',
|
||||
with: [
|
||||
'summary' => $this->summary,
|
||||
'severity' => $this->severity,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Уведомление о пропавшем или постоянно падающем cron-задаче.
|
||||
*
|
||||
* Триггер: SchedulerCheckHeartbeats обнаружил:
|
||||
* • отсутствие пульса > 2× ожидаемого интервала, ИЛИ
|
||||
* • consecutive_failures >= 3.
|
||||
*
|
||||
* Отправляется на kdv1@bk.ru (monitoring email).
|
||||
*/
|
||||
final class SchedulerHeartbeatMissingMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $commandName,
|
||||
public readonly string $reason,
|
||||
public readonly ?string $lastError,
|
||||
public readonly int $consecutiveFailures,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "[Лидерра HIGH] Scheduler heartbeat missing: {$this->commandName}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.scheduler_heartbeat_missing_text',
|
||||
with: [
|
||||
'commandName' => $this->commandName,
|
||||
'reason' => $this->reason,
|
||||
'lastError' => $this->lastError,
|
||||
'consecutiveFailures' => $this->consecutiveFailures,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Обращение субъекта ПДн (152-ФЗ).
|
||||
*
|
||||
* SaaS-уровневая таблица — RLS не применяется. Доступ только из
|
||||
* AdminPdSubjectRequestsController под saas-admin middleware.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $received_at
|
||||
* @property string|null $subject_email
|
||||
* @property string|null $subject_phone
|
||||
* @property string|null $subject_full_name
|
||||
* @property string $request_type access|rectification|deletion|objection
|
||||
* @property string|null $description
|
||||
* @property string $status received|in_progress|completed|rejected
|
||||
* @property int|null $tenant_id
|
||||
* @property int|null $assigned_admin_id
|
||||
* @property string|null $response_sent_at
|
||||
* @property string|null $response_text
|
||||
* @property string $deadline_at
|
||||
* @property string|null $completed_at
|
||||
* @property bool $processing_restricted
|
||||
*/
|
||||
class PdSubjectRequest extends Model
|
||||
{
|
||||
/**
|
||||
* SaaS-уровневая таблица — crm_app_user (default) не имеет INSERT/UPDATE прав.
|
||||
* Используем pgsql_supplier (BYPASSRLS / crm_supplier_worker), который имеет
|
||||
* полный доступ. Альтернатива — GRANT для crm_app_user, но это размывает
|
||||
* границу tenant-уровня (см. db/00_create_roles.sql).
|
||||
*/
|
||||
protected $connection = 'pgsql_supplier';
|
||||
|
||||
protected $table = 'pd_subject_requests';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'received_at',
|
||||
'subject_email',
|
||||
'subject_phone',
|
||||
'subject_full_name',
|
||||
'request_type',
|
||||
'description',
|
||||
'status',
|
||||
'tenant_id',
|
||||
'assigned_admin_id',
|
||||
'response_sent_at',
|
||||
'response_text',
|
||||
'deadline_at',
|
||||
'completed_at',
|
||||
'processing_restricted',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'received_at' => 'datetime',
|
||||
'response_sent_at' => 'datetime',
|
||||
'deadline_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'processing_restricted' => 'boolean',
|
||||
'tenant_id' => 'integer',
|
||||
'assigned_admin_id' => 'integer',
|
||||
];
|
||||
|
||||
/** Тенант, к которому относится обращение (nullable). */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS-админ, назначенный исполнителем.
|
||||
*
|
||||
* NB: модель SaasAdminUser не создана — используем User как фиктивный базис.
|
||||
* В реальном коде — DB::table('saas_admin_users') напрямую в контроллере.
|
||||
*/
|
||||
// assignedAdmin: нет Eloquent-модели SaasAdminUser — читается напрямую через DB
|
||||
}
|
||||
@@ -9,19 +9,40 @@ use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
|
||||
* Создаёт месячные RANGE-партиции для таблиц, партиционированных помесячно.
|
||||
*
|
||||
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
|
||||
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
|
||||
*
|
||||
* Используется:
|
||||
* - cron `partitions:create-months` — N месяцев вперёд;
|
||||
* - `partitions:drop-expired` — дропает старые партиции;
|
||||
* - HistoricalImportService — под исторический диапазон дат CSV.
|
||||
*
|
||||
* Hole #2 (23.05.2026): расширен до 9 таблиц (+7 audit-таблиц).
|
||||
* Ключ партиционирования теперь задаётся per-table в PARTITIONED_TABLES map.
|
||||
*/
|
||||
class MonthlyPartitionManager
|
||||
{
|
||||
/** @var array<int, string> Таблицы, партиционированные по received_at помесячно. */
|
||||
public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
|
||||
/**
|
||||
* Таблицы, партиционированные помесячно.
|
||||
* Ключ → имя таблицы, значение → колонка-ключ партиционирования.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const PARTITIONED_TABLES = [
|
||||
// Бизнес-таблицы (исходные)
|
||||
'deals' => 'received_at',
|
||||
'supplier_lead_costs' => 'received_at',
|
||||
// Audit-таблицы (hole #2, 23.05.2026)
|
||||
'auth_log' => 'created_at',
|
||||
'activity_log' => 'created_at',
|
||||
'tenant_operations_log' => 'created_at',
|
||||
'webhook_log' => 'received_at',
|
||||
'balance_transactions' => 'created_at',
|
||||
'pd_processing_log' => 'created_at',
|
||||
'saas_admin_audit_log' => 'created_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Гарантирует наличие месячных партиций таблицы для всех месяцев,
|
||||
@@ -31,9 +52,7 @@ class MonthlyPartitionManager
|
||||
*/
|
||||
public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
$this->assertKnownTable($table);
|
||||
|
||||
$month = $from->copy()->startOfMonth();
|
||||
$last = $to->copy()->startOfMonth();
|
||||
@@ -53,13 +72,14 @@ class MonthlyPartitionManager
|
||||
*/
|
||||
public function ensureMonth(string $table, CarbonInterface $monthStart): bool
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
$this->assertKnownTable($table);
|
||||
|
||||
$partitionKey = self::PARTITIONED_TABLES[$table];
|
||||
$start = $monthStart->copy()->startOfMonth();
|
||||
$end = $start->copy()->addMonth();
|
||||
$partition = sprintf('%s_%s', $table, $start->format('Y_m'));
|
||||
|
||||
// Partition naming: <table>_y<YYYY>_m<MM>
|
||||
$partition = sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));
|
||||
|
||||
$exists = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
@@ -80,4 +100,45 @@ class MonthlyPartitionManager
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает имя партиции для заданной таблицы и месяца.
|
||||
* Утилита для тестов и команды drop-expired.
|
||||
*/
|
||||
public function partitionName(string $table, CarbonInterface $monthStart): string
|
||||
{
|
||||
$this->assertKnownTable($table);
|
||||
$start = $monthStart->copy()->startOfMonth();
|
||||
|
||||
return sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список существующих партиций для таблицы через pg_inherits.
|
||||
*
|
||||
* @return list<string> Имена партиций (relname).
|
||||
*/
|
||||
public function listPartitions(string $table): array
|
||||
{
|
||||
$this->assertKnownTable($table);
|
||||
|
||||
$rows = DB::select(
|
||||
'SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = ?
|
||||
ORDER BY c.relname',
|
||||
[$table],
|
||||
);
|
||||
|
||||
return array_map(fn ($r) => $r->relname, $rows);
|
||||
}
|
||||
|
||||
private function assertKnownTable(string $table): void
|
||||
{
|
||||
if (! array_key_exists($table, self::PARTITIONED_TABLES)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Сервис анонимизации ПДн субъекта по 152-ФЗ (право на удаление, ст.21).
|
||||
*
|
||||
* Использует соединение pgsql_supplier (BYPASSRLS / crm_supplier_worker),
|
||||
* чтобы читать и писать cross-tenant без RLS-ограничений.
|
||||
*
|
||||
* Реальные колонки схемы v8.19:
|
||||
* users: email, first_name, last_name, phone
|
||||
* supplier_leads: phone, raw_payload (JSONB) — нет contact_email/contact_phone
|
||||
* deals: phone, contact_name — нет отдельного contact_email
|
||||
* webhook_log: raw_payload (JSONB)
|
||||
*/
|
||||
class PdErasureService
|
||||
{
|
||||
private const DB = 'pgsql_supplier';
|
||||
|
||||
/**
|
||||
* Анонимизировать все ПДн субъекта по email и/или телефону.
|
||||
*
|
||||
* @param string|null $email Email субъекта (один из двух обязателен)
|
||||
* @param string|null $phone Телефон субъекта (один из двух обязателен)
|
||||
* @param int|null $tenantId Ограничить поиск одним тенантом (null = все)
|
||||
* @param int $actorAdminId ID saas_admin_users
|
||||
* @param string|null $requestId ID pd_subject_requests для авто-закрытия
|
||||
* @return array{users: int, leads: int, deals: int, webhook_log: int}
|
||||
*
|
||||
* @throws InvalidArgumentException если оба email и phone null
|
||||
*/
|
||||
public function eraseSubject(
|
||||
?string $email,
|
||||
?string $phone,
|
||||
?int $tenantId,
|
||||
int $actorAdminId,
|
||||
?string $requestId = null,
|
||||
): array {
|
||||
if ($email === null && $phone === null) {
|
||||
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
|
||||
}
|
||||
|
||||
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0];
|
||||
|
||||
DB::connection(self::DB)->transaction(function () use (
|
||||
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
|
||||
): void {
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. users
|
||||
// ------------------------------------------------------------------
|
||||
$userQuery = DB::connection(self::DB)->table('users');
|
||||
$userQuery->where(function ($q) use ($email, $phone): void {
|
||||
if ($email !== null) {
|
||||
$q->orWhere('email', $email);
|
||||
}
|
||||
if ($phone !== null) {
|
||||
$q->orWhere('phone', $phone);
|
||||
}
|
||||
});
|
||||
if ($tenantId !== null) {
|
||||
$userQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$users = $userQuery->get(['id', 'tenant_id']);
|
||||
|
||||
foreach ($users as $user) {
|
||||
$userId = (int) $user->id;
|
||||
$userTenantId = (int) $user->tenant_id;
|
||||
|
||||
DB::connection(self::DB)->table('users')
|
||||
->where('id', $userId)
|
||||
->update([
|
||||
'email' => 'erased-'.$userId.'@deleted.local',
|
||||
'first_name' => 'Удалено',
|
||||
'last_name' => null,
|
||||
'phone' => '+7000'.str_pad((string) $userId, 7, '0', STR_PAD_LEFT),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->writePdLog(
|
||||
tenantId: $userTenantId,
|
||||
subjectType: 'user',
|
||||
subjectId: $userId,
|
||||
actorAdminId: $actorAdminId,
|
||||
now: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$counts['users'] = $users->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. supplier_leads (phone + raw_payload JSONB)
|
||||
// NB: нет contact_email / contact_phone — поиск только по phone
|
||||
// ------------------------------------------------------------------
|
||||
$leadQuery = DB::connection(self::DB)->table('supplier_leads');
|
||||
if ($phone !== null) {
|
||||
$leadQuery->where('phone', $phone);
|
||||
} else {
|
||||
// Только email — ищем в raw_payload JSONB
|
||||
$leadQuery->whereRaw('raw_payload::text LIKE ?', ['%'.$email.'%']);
|
||||
}
|
||||
|
||||
$leads = $leadQuery->get(['id']);
|
||||
|
||||
foreach ($leads as $lead) {
|
||||
$leadId = (int) $lead->id;
|
||||
|
||||
DB::connection(self::DB)->table('supplier_leads')
|
||||
->where('id', $leadId)
|
||||
->update([
|
||||
'phone' => '+7000XXXXXXX',
|
||||
'raw_payload' => DB::connection(self::DB)->raw(
|
||||
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
|
||||
),
|
||||
]);
|
||||
|
||||
$this->writePdLog(
|
||||
tenantId: $tenantId,
|
||||
subjectType: 'lead',
|
||||
subjectId: $leadId,
|
||||
actorAdminId: $actorAdminId,
|
||||
now: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$counts['leads'] = $leads->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. deals (phone + contact_name)
|
||||
// Deals партиционированы — UPDATE без WHERE на партиции через
|
||||
// parent table работает начиная с PG 11+.
|
||||
// ------------------------------------------------------------------
|
||||
$dealQuery = DB::connection(self::DB)->table('deals');
|
||||
$dealQuery->where(function ($q) use ($email, $phone): void {
|
||||
if ($phone !== null) {
|
||||
$q->orWhere('phone', $phone);
|
||||
}
|
||||
if ($email !== null) {
|
||||
// Дополнительно: UTM/phones JSONB может хранить email, но в
|
||||
// минимуме ищем только по phone. Email в deals не хранится
|
||||
// в отдельной колонке.
|
||||
}
|
||||
});
|
||||
if ($tenantId !== null) {
|
||||
$dealQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
// Исключаем строки без совпадения по phone (когда phone=null — ничего не ищем)
|
||||
if ($phone === null) {
|
||||
// deals не имеет email-колонки, пропускаем
|
||||
$dealQuery->whereRaw('FALSE');
|
||||
}
|
||||
|
||||
$deals = $dealQuery->get(['id']);
|
||||
|
||||
foreach ($deals as $deal) {
|
||||
$dealId = (int) $deal->id;
|
||||
|
||||
DB::connection(self::DB)->table('deals')
|
||||
->where('id', $dealId)
|
||||
->update([
|
||||
'phone' => '+7000XXXXXXX',
|
||||
'contact_name' => 'Удалено',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$counts['deals'] = $deals->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. webhook_log (raw_payload JSONB text-search)
|
||||
// ------------------------------------------------------------------
|
||||
$wlQuery = DB::connection(self::DB)->table('webhook_log');
|
||||
$conditions = [];
|
||||
$bindings = [];
|
||||
if ($email !== null) {
|
||||
$conditions[] = 'raw_payload::text LIKE ?';
|
||||
$bindings[] = '%'.$email.'%';
|
||||
}
|
||||
if ($phone !== null) {
|
||||
$conditions[] = 'raw_payload::text LIKE ?';
|
||||
$bindings[] = '%'.$phone.'%';
|
||||
}
|
||||
|
||||
if (! empty($conditions)) {
|
||||
$wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings);
|
||||
}
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$wlQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
// Batched update: обрабатываем по 500 строк
|
||||
$wlCount = 0;
|
||||
$wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void {
|
||||
$ids = $rows->pluck('id')->all();
|
||||
DB::connection(self::DB)->table('webhook_log')
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
'raw_payload' => DB::connection(self::DB)->raw(
|
||||
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
|
||||
),
|
||||
]);
|
||||
$wlCount += count($ids);
|
||||
});
|
||||
|
||||
$counts['webhook_log'] = $wlCount;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. Обновить pd_subject_requests если requestId передан
|
||||
// ------------------------------------------------------------------
|
||||
if ($requestId !== null) {
|
||||
$summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, "
|
||||
."deals={$counts['deals']}, webhook_log={$counts['webhook_log']}";
|
||||
|
||||
DB::connection(self::DB)->table('pd_subject_requests')
|
||||
->where('id', $requestId)
|
||||
->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => $now,
|
||||
'response_text' => $summary,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставить запись в pd_processing_log через BYPASSRLS-соединение.
|
||||
*/
|
||||
private function writePdLog(
|
||||
?int $tenantId,
|
||||
string $subjectType,
|
||||
int $subjectId,
|
||||
int $actorAdminId,
|
||||
CarbonImmutable $now,
|
||||
): void {
|
||||
DB::connection(self::DB)->table('pd_processing_log')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'subject_type' => $subjectType,
|
||||
'subject_id' => $subjectId,
|
||||
'action' => 'deleted',
|
||||
'purpose' => '152-FZ erasure',
|
||||
'actor_admin_user_id' => $actorAdminId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Трекер пульса планировщика задач (hole #6).
|
||||
*
|
||||
* Оборачивает каждую cron-задачу: фиксирует время запуска, длительность,
|
||||
* результат (успех / ошибка) и consecutive_failures в scheduler_heartbeats.
|
||||
*
|
||||
* Использует pgsql_supplier (BYPASSRLS, crm_supplier_worker) — SaaS-level таблица,
|
||||
* RLS не применяется. Паттерн аналогичен IncidentsWatchFailures.
|
||||
*/
|
||||
final class SchedulerHeartbeatTracker
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/**
|
||||
* Ожидаемые интервалы cron-задач в минутах.
|
||||
* Используется SchedulerCheckHeartbeats для детекции пропавшего пульса.
|
||||
*/
|
||||
public const EXPECTED_INTERVALS = [
|
||||
'projects:reset-delivered-today' => 1440, // daily
|
||||
'projects:reset-monthly' => 43200, // monthly (~30 days)
|
||||
'partitions:create-months' => 1440, // daily
|
||||
'App\Jobs\Supplier\RefreshSupplierSessionJob@hourly' => 60,
|
||||
'App\Jobs\Supplier\RefreshSupplierSessionJob@daily' => 1440,
|
||||
'App\Jobs\Supplier\SyncSupplierProjectsJob' => 1440,
|
||||
'App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob' => 1440,
|
||||
'supplier:retry-failed' => 60, // hourly
|
||||
'App\Jobs\Supplier\CsvReconcileJob' => 30, // every 30 min
|
||||
'incidents:watch-failures' => 10, // every 10 min
|
||||
'audit:verify-chains' => 1440, // daily
|
||||
'partitions:drop-expired' => 10080, // weekly (Sunday 03:00 MSK)
|
||||
'scheduler:check-heartbeats' => 60, // hourly (self-check)
|
||||
];
|
||||
|
||||
/**
|
||||
* Выполняет $work, записывает heartbeat (успех или ошибку).
|
||||
* Исключение пробрасывается наружу после сохранения.
|
||||
* Используется в тестах и при прямой инвокации.
|
||||
*/
|
||||
public function recordRun(string $name, callable $work): void
|
||||
{
|
||||
$startedAt = now();
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$work();
|
||||
} catch (Throwable $e) {
|
||||
$error = substr($e->getMessage(), 0, 2000);
|
||||
throw $e;
|
||||
} finally {
|
||||
$runtimeMs = (int) ($startedAt->diffInMilliseconds(now()));
|
||||
$this->saveHeartbeat($name, $startedAt, $error, $runtimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Записывает результат запуска напрямую (без обёртки callable).
|
||||
* Используется из before/after/onFailure хуков routes/console.php.
|
||||
*
|
||||
* @param bool $success true = успех, false = ошибка
|
||||
* @param string|null $errorMsg сообщение ошибки при $success=false
|
||||
* @param int|null $runtimeMs длительность в мс (null если неизвестна)
|
||||
*/
|
||||
public function recordRunResult(
|
||||
string $name,
|
||||
bool $success,
|
||||
?string $errorMsg,
|
||||
?int $runtimeMs,
|
||||
): void {
|
||||
$this->saveHeartbeat($name, now(), $success ? null : $errorMsg, $runtimeMs ?? 0);
|
||||
}
|
||||
|
||||
private function saveHeartbeat(
|
||||
string $name,
|
||||
\DateTimeInterface $startedAt,
|
||||
?string $error,
|
||||
int $runtimeMs,
|
||||
): void {
|
||||
$now = now();
|
||||
$db = DB::connection(self::DB_CONNECTION);
|
||||
|
||||
if ($error === null) {
|
||||
// Успех: сбрасываем consecutive_failures
|
||||
$db->statement(
|
||||
<<<'SQL'
|
||||
INSERT INTO scheduler_heartbeats
|
||||
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
||||
VALUES
|
||||
(?, ?, ?, NULL, ?, 0, ?, ?)
|
||||
ON CONFLICT (command_name) DO UPDATE SET
|
||||
last_run_at = EXCLUDED.last_run_at,
|
||||
last_success_at = EXCLUDED.last_success_at,
|
||||
last_error = NULL,
|
||||
runtime_ms = EXCLUDED.runtime_ms,
|
||||
consecutive_failures = 0,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
SQL,
|
||||
[$name, $startedAt, $now, $runtimeMs, $now, $now],
|
||||
);
|
||||
} else {
|
||||
// Ошибка: инкрементируем consecutive_failures
|
||||
$db->statement(
|
||||
<<<'SQL'
|
||||
INSERT INTO scheduler_heartbeats
|
||||
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
||||
VALUES
|
||||
(?, ?, NULL, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT (command_name) DO UPDATE SET
|
||||
last_run_at = EXCLUDED.last_run_at,
|
||||
last_error = EXCLUDED.last_error,
|
||||
runtime_ms = EXCLUDED.runtime_ms,
|
||||
consecutive_failures = scheduler_heartbeats.consecutive_failures + 1,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
SQL,
|
||||
[$name, $startedAt, $error, $runtimeMs, $now, $now],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -59,6 +60,12 @@ return new class extends Migration
|
||||
// с deals), поэтому DB::unprepared его успешно применил — повторный ALTER
|
||||
// здесь не нужен. Если в будущем PDO начнёт глотать FK на partitioned —
|
||||
// повторить паттерн webhook_dedup_keys с try/catch ('уже существует' RU + EN).
|
||||
|
||||
// v8.31 (hole #2): создаём начальные партиции для 9 партиционированных таблиц
|
||||
// (deals, supplier_lead_costs + 7 audit-таблиц). Без этого первый INSERT
|
||||
// упадёт с "no partition found for row". Cron partitions:create-months
|
||||
// поддерживает их далее (текущий + ahead, default 2 месяца вперёд).
|
||||
Artisan::call('partitions:create-months', ['--ahead' => 2]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
||||
@@ -7,6 +7,14 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Idempotency guard: if schema.sql was loaded first, the table already exists
|
||||
// (as a partitioned table from hole #2 migration). Skip creation in that case.
|
||||
// The 2026_05_23_000002_partition_audit_tables migration will handle the partitioned
|
||||
// form when it runs.
|
||||
if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['tenant_operations_log']) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = file_get_contents(base_path('../db/migrations/2026_05_22_001_tenant_operations_log.sql'));
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Migration SQL file not found.');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Hole #6: таблица пульса планировщика.
|
||||
*
|
||||
* SaaS-level, без RLS. Одна строка на cron-задачу (PK = command_name).
|
||||
* Обновляется SchedulerHeartbeatTracker при каждом запуске задачи.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS scheduler_heartbeats (
|
||||
command_name VARCHAR(200) NOT NULL PRIMARY KEY,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
runtime_ms INT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE scheduler_heartbeats IS
|
||||
'Пульс планировщика: одна строка на cron-задачу, обновляется при каждом запуске. '
|
||||
'SaaS-level, без RLS. Используется SchedulerCheckHeartbeats для детекции '
|
||||
'пропавших или постоянно падающих задач (hole #6).';
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::unprepared('DROP TABLE IF EXISTS scheduler_heartbeats;');
|
||||
}
|
||||
};
|
||||
Generated
+104
-29
@@ -14,12 +14,15 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"axios": "^1.16.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"histoire": "^1.0.0-beta.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.12.2",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
@@ -4084,22 +4087,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
|
||||
@@ -4134,14 +4155,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
@@ -5117,6 +5135,23 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
@@ -5130,6 +5165,13 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
@@ -5302,6 +5344,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -5775,6 +5834,30 @@
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -6380,14 +6463,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
@@ -6452,9 +6534,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -6995,13 +7077,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
|
||||
@@ -23,12 +23,15 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"axios": "^1.16.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"histoire": "^1.0.0-beta.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.12.2",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
|
||||
@@ -494,3 +494,68 @@ export async function updateAdminSupplier(
|
||||
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 152-ФЗ: обращения субъектов ПДн
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PdSubjectRequest {
|
||||
id: number;
|
||||
received_at: string;
|
||||
subject_email: string | null;
|
||||
subject_phone: string | null;
|
||||
subject_full_name: string | null;
|
||||
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
|
||||
description: string | null;
|
||||
status: 'received' | 'in_progress' | 'completed' | 'rejected';
|
||||
tenant_id: number | null;
|
||||
assigned_admin_id: number | null;
|
||||
response_text: string | null;
|
||||
deadline_at: string;
|
||||
completed_at: string | null;
|
||||
processing_restricted: boolean;
|
||||
}
|
||||
|
||||
export interface ListPdRequestsResponse {
|
||||
data: PdSubjectRequest[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface CreatePdRequestPayload {
|
||||
subject_email?: string;
|
||||
subject_phone?: string;
|
||||
subject_full_name?: string;
|
||||
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
|
||||
description?: string;
|
||||
tenant_id?: number | null;
|
||||
}
|
||||
|
||||
export interface EraseSubjectResult {
|
||||
message: string;
|
||||
counts: { users: number; leads: number; deals: number; webhook_log: number };
|
||||
}
|
||||
|
||||
export async function listPdSubjectRequests(
|
||||
params: { status?: string; request_type?: string; limit?: number; offset?: number } = {},
|
||||
): Promise<ListPdRequestsResponse> {
|
||||
const { data } = await apiClient.get<ListPdRequestsResponse>('/api/admin/pd-subject-requests', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPdSubjectRequest(payload: CreatePdRequestPayload): Promise<PdSubjectRequest> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ data: PdSubjectRequest }>('/api/admin/pd-subject-requests', payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function executePdErasure(id: number, adminUserId?: number): Promise<EraseSubjectResult> {
|
||||
await ensureCsrfCookie();
|
||||
const payload = adminUserId !== undefined ? { admin_user_id: adminUserId } : {};
|
||||
const { data } = await apiClient.post<EraseSubjectResult>(
|
||||
`/api/admin/pd-subject-requests/${id}/erase`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
item-value="code"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-add-select"
|
||||
@@ -43,6 +44,7 @@
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-remove-select"
|
||||
|
||||
@@ -34,6 +34,7 @@ const navItems: NavItem[] = [
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
|
||||
{ title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' },
|
||||
{ title: 'Обращения ПДн (152-ФЗ)', icon: 'mdi-shield-account-outline', to: '/admin/pd-subject-requests' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -295,6 +295,18 @@ const routes: RouteRecordRaw[] = [
|
||||
devLabel: 'Admin Supplier Projects',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/pd-subject-requests',
|
||||
name: 'admin-pd-subject-requests',
|
||||
component: () => import('../views/admin/AdminPdSubjectRequestsView.vue'),
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Обращения ПДн',
|
||||
requiresAuth: true,
|
||||
devIndex: 32,
|
||||
devLabel: 'Admin PD Requests',
|
||||
},
|
||||
},
|
||||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||||
{
|
||||
path: '/403',
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Adminка SaaS → Обращения субъектов ПДн (152-ФЗ).
|
||||
*
|
||||
* Список обращений на удаление/доступ/исправление/возражение.
|
||||
* Для request_type='deletion' — кнопка «Анонимизировать» (§ 1.5, дыра #4).
|
||||
*
|
||||
* API: GET/POST /api/admin/pd-subject-requests, POST /{id}/erase
|
||||
*/
|
||||
import { onMounted, ref, reactive, computed } from 'vue';
|
||||
import * as adminApi from '../../api/admin';
|
||||
import type { PdSubjectRequest, CreatePdRequestPayload } from '../../api/admin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
const rows = ref<PdSubjectRequest[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
const filterStatus = ref('');
|
||||
const filterType = ref('');
|
||||
|
||||
// Dialog: create
|
||||
const createDialog = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const createError = ref('');
|
||||
const createForm = reactive<CreatePdRequestPayload>({
|
||||
subject_email: '',
|
||||
subject_phone: '',
|
||||
subject_full_name: '',
|
||||
request_type: 'deletion',
|
||||
description: '',
|
||||
tenant_id: null,
|
||||
});
|
||||
|
||||
// Dialog: erase confirm
|
||||
const eraseDialog = ref(false);
|
||||
const eraseLoading = ref(false);
|
||||
const eraseTarget = ref<PdSubjectRequest | null>(null);
|
||||
const eraseResult = ref<{ users: number; leads: number; deals: number; webhook_log: number } | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load data
|
||||
// ---------------------------------------------------------------------------
|
||||
async function loadRows(): Promise<void> {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const res = await adminApi.listPdSubjectRequests({
|
||||
status: filterStatus.value || undefined,
|
||||
request_type: filterType.value || undefined,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
rows.value = res.data;
|
||||
total.value = res.total;
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRows);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create request
|
||||
// ---------------------------------------------------------------------------
|
||||
async function submitCreate(): Promise<void> {
|
||||
createError.value = '';
|
||||
if (!createForm.subject_email && !createForm.subject_phone) {
|
||||
createError.value = 'Укажите email или телефон субъекта.';
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await adminApi.createPdSubjectRequest({
|
||||
subject_email: createForm.subject_email || undefined,
|
||||
subject_phone: createForm.subject_phone || undefined,
|
||||
subject_full_name: createForm.subject_full_name || undefined,
|
||||
request_type: createForm.request_type,
|
||||
description: createForm.description || undefined,
|
||||
tenant_id: createForm.tenant_id ?? undefined,
|
||||
});
|
||||
createDialog.value = false;
|
||||
resetCreateForm();
|
||||
await loadRows();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
createError.value = err?.response?.data?.message ?? 'Ошибка при создании обращения.';
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateForm(): void {
|
||||
createForm.subject_email = '';
|
||||
createForm.subject_phone = '';
|
||||
createForm.subject_full_name = '';
|
||||
createForm.request_type = 'deletion';
|
||||
createForm.description = '';
|
||||
createForm.tenant_id = null;
|
||||
createError.value = '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erase
|
||||
// ---------------------------------------------------------------------------
|
||||
function openErase(row: PdSubjectRequest): void {
|
||||
eraseTarget.value = row;
|
||||
eraseResult.value = null;
|
||||
eraseDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmErase(): Promise<void> {
|
||||
if (!eraseTarget.value) return;
|
||||
eraseLoading.value = true;
|
||||
try {
|
||||
const res = await adminApi.executePdErasure(eraseTarget.value.id);
|
||||
eraseResult.value = res.counts;
|
||||
// Update row status in list
|
||||
const idx = rows.value.findIndex((r) => r.id === eraseTarget.value?.id);
|
||||
if (idx !== -1) {
|
||||
rows.value[idx] = { ...rows.value[idx], status: 'completed' };
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
alert(err?.response?.data?.message ?? 'Ошибка анонимизации.');
|
||||
eraseDialog.value = false;
|
||||
} finally {
|
||||
eraseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
received: { label: 'Получено', color: 'info' },
|
||||
in_progress: { label: 'В работе', color: 'warning' },
|
||||
completed: { label: 'Выполнено', color: 'success' },
|
||||
rejected: { label: 'Отклонено', color: 'error' },
|
||||
};
|
||||
function statusInfo(s: string) {
|
||||
return statusLabels[s] ?? { label: s, color: 'default' };
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
access: 'Доступ',
|
||||
rectification: 'Исправление',
|
||||
deletion: 'Удаление',
|
||||
objection: 'Возражение',
|
||||
};
|
||||
function typeLabel(t: string): string {
|
||||
return typeLabels[t] ?? t;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const headers = [
|
||||
{ title: 'ID', key: 'id', width: '60px' },
|
||||
{ title: 'Получено', key: 'received_at', width: '140px' },
|
||||
{ title: 'Email / тел.', key: 'contact', sortable: false },
|
||||
{ title: 'Тип', key: 'request_type', width: '110px' },
|
||||
{ title: 'Статус', key: 'status', width: '120px' },
|
||||
{ title: 'Дедлайн', key: 'deadline_at', width: '140px' },
|
||||
{ title: 'Действия', key: 'actions', sortable: false, width: '140px', align: 'end' as const },
|
||||
];
|
||||
|
||||
const filteredRows = computed(() => rows.value);
|
||||
|
||||
defineExpose({ rows, loading, fetchError, loadRows });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-pd pa-6">
|
||||
<!-- Page head -->
|
||||
<header class="page-head mb-4 d-flex justify-space-between align-start flex-wrap ga-3">
|
||||
<div>
|
||||
<h1 class="text-h4 page-title">Обращения субъектов ПДн</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||||
Обращения на доступ, исправление, удаление и возражение (152-ФЗ).
|
||||
Срок ответа — 30 дней.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="loading"
|
||||
data-testid="reload-btn"
|
||||
@click="loadRows"
|
||||
>
|
||||
Обновить
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="create-btn"
|
||||
@click="createDialog = true"
|
||||
>
|
||||
Новый запрос
|
||||
</v-btn>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
data-testid="fetch-error-alert"
|
||||
>
|
||||
Не удалось загрузить обращения. Попробуйте обновить.
|
||||
</v-alert>
|
||||
|
||||
<!-- Filters -->
|
||||
<v-card variant="outlined" class="pa-3 mb-4">
|
||||
<v-row dense>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
label="Статус"
|
||||
:items="[
|
||||
{ title: 'Все статусы', value: '' },
|
||||
{ title: 'Получено', value: 'received' },
|
||||
{ title: 'В работе', value: 'in_progress' },
|
||||
{ title: 'Выполнено', value: 'completed' },
|
||||
{ title: 'Отклонено', value: 'rejected' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="loadRows"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-select
|
||||
v-model="filterType"
|
||||
label="Тип обращения"
|
||||
:items="[
|
||||
{ title: 'Все типы', value: '' },
|
||||
{ title: 'Доступ', value: 'access' },
|
||||
{ title: 'Исправление', value: 'rectification' },
|
||||
{ title: 'Удаление', value: 'deletion' },
|
||||
{ title: 'Возражение', value: 'objection' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="loadRows"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4" class="d-flex align-center">
|
||||
<span class="text-body-2 text-medium-emphasis">Всего: {{ total }}</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<!-- Table -->
|
||||
<v-card variant="outlined">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredRows"
|
||||
:loading="loading"
|
||||
item-value="id"
|
||||
density="compact"
|
||||
no-data-text="Обращений нет"
|
||||
data-testid="pd-requests-table"
|
||||
>
|
||||
<template v-slot:[`item.received_at`]="{ item }">
|
||||
<span class="text-caption font-mono">{{ formatDate(item.received_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.contact`]="{ item }">
|
||||
<div>
|
||||
<span v-if="item.subject_email" class="d-block text-body-2">{{ item.subject_email }}</span>
|
||||
<span v-if="item.subject_phone" class="d-block text-caption text-medium-emphasis">
|
||||
{{ item.subject_phone }}
|
||||
</span>
|
||||
<span v-if="item.subject_full_name" class="d-block text-caption text-medium-emphasis">
|
||||
{{ item.subject_full_name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.request_type`]="{ item }">
|
||||
<v-chip
|
||||
:color="item.request_type === 'deletion' ? 'error' : 'default'"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ typeLabel(item.request_type) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.status`]="{ item }">
|
||||
<v-chip
|
||||
:color="statusInfo(item.status).color"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ statusInfo(item.status).label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.deadline_at`]="{ item }">
|
||||
<span
|
||||
class="text-caption"
|
||||
:class="item.status !== 'completed' && new Date(item.deadline_at) < new Date() ? 'text-error' : ''"
|
||||
>
|
||||
{{ formatDate(item.deadline_at) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<v-btn
|
||||
v-if="item.request_type === 'deletion' && item.status !== 'completed'"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-delete-forever"
|
||||
:data-testid="`erase-btn-${item.id}`"
|
||||
@click="openErase(item)"
|
||||
>
|
||||
Анонимизировать
|
||||
</v-btn>
|
||||
<v-chip
|
||||
v-else-if="item.status === 'completed'"
|
||||
color="success"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
>
|
||||
Выполнено
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Dialog: create request -->
|
||||
<v-dialog v-model="createDialog" max-width="520" data-testid="create-dialog">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 pa-4 pb-2">Новое обращение субъекта ПДн</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<v-alert
|
||||
v-if="createError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
{{ createError }}
|
||||
</v-alert>
|
||||
|
||||
<v-select
|
||||
v-model="createForm.request_type"
|
||||
label="Тип обращения *"
|
||||
:items="[
|
||||
{ title: 'Доступ к данным', value: 'access' },
|
||||
{ title: 'Исправление данных', value: 'rectification' },
|
||||
{ title: 'Удаление данных', value: 'deletion' },
|
||||
{ title: 'Возражение', value: 'objection' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-3"
|
||||
data-testid="form-request-type"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="createForm.subject_email"
|
||||
label="Email субъекта"
|
||||
type="email"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
data-testid="form-email"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="createForm.subject_phone"
|
||||
label="Телефон субъекта"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
data-testid="form-phone"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="createForm.subject_full_name"
|
||||
label="ФИО субъекта"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="createForm.tenant_id"
|
||||
label="ID тенанта (необязательно)"
|
||||
type="number"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="createForm.description"
|
||||
label="Описание"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0 justify-end">
|
||||
<v-btn variant="text" @click="createDialog = false; resetCreateForm()">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="createLoading"
|
||||
data-testid="submit-create-btn"
|
||||
@click="submitCreate"
|
||||
>
|
||||
Создать
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog: erase confirm -->
|
||||
<v-dialog v-model="eraseDialog" max-width="480" data-testid="erase-dialog">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 pa-4 pb-2 text-error">
|
||||
Анонимизировать данные субъекта
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<template v-if="!eraseResult">
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
Операция необратима. Данные будут заменены плейсхолдерами.
|
||||
</v-alert>
|
||||
<p class="text-body-2 mb-1">
|
||||
<strong>Email:</strong> {{ eraseTarget?.subject_email ?? '—' }}
|
||||
</p>
|
||||
<p class="text-body-2 mb-1">
|
||||
<strong>Телефон:</strong> {{ eraseTarget?.subject_phone ?? '—' }}
|
||||
</p>
|
||||
<p class="text-body-2">
|
||||
<strong>Тенант:</strong> {{ eraseTarget?.tenant_id ?? 'все' }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-alert type="success" variant="tonal" density="compact" class="mb-3">
|
||||
Анонимизация выполнена.
|
||||
</v-alert>
|
||||
<p class="text-body-2 mb-1">Пользователей: <strong>{{ eraseResult.users }}</strong></p>
|
||||
<p class="text-body-2 mb-1">Лидов поставщика: <strong>{{ eraseResult.leads }}</strong></p>
|
||||
<p class="text-body-2 mb-1">Сделок: <strong>{{ eraseResult.deals }}</strong></p>
|
||||
<p class="text-body-2">Webhook-логов: <strong>{{ eraseResult.webhook_log }}</strong></p>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0 justify-end">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="eraseDialog = false"
|
||||
>
|
||||
{{ eraseResult ? 'Закрыть' : 'Отмена' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!eraseResult"
|
||||
color="error"
|
||||
:loading="eraseLoading"
|
||||
data-testid="confirm-erase-btn"
|
||||
@click="confirmErase"
|
||||
>
|
||||
Подтвердить удаление
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-pd {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-title {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -94,6 +94,7 @@
|
||||
:disabled="vsyaRfConfirmed"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
class="ld-input-quiet"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
Аудит hash-chain: РАЗРЫВ ЦЕПОЧКИ
|
||||
===================================
|
||||
Таблица: {{ $tableName }}
|
||||
Партиция: {{ $partitionName }}
|
||||
Первый сломанный id: {{ $firstBrokenId }}
|
||||
Несовпадений: {{ $mismatchCount }}
|
||||
Время: {{ $now }} (МСК)
|
||||
|
||||
ВНИМАНИЕ: разрыв hash-chain означает, что строки в партиции {{ $partitionName }}
|
||||
(таблица {{ $tableName }}) могли быть изменены или удалены в обход триггеров
|
||||
(прямой SQL под суперюзером).
|
||||
|
||||
Необходимо срочно:
|
||||
1. Проверить pg_audit log на предмет DDL/UPDATE/DELETE без триггеров.
|
||||
2. Проверить incidents_log для деталей.
|
||||
3. Восстановить данные из backup при необходимости.
|
||||
|
||||
Это автоматическое сообщение от команды audit:verify-chains (Лидерра).
|
||||
@@ -0,0 +1,10 @@
|
||||
Автоматический инцидент — Лидерра
|
||||
===================================
|
||||
Severity: {{ strtoupper($severity) }}
|
||||
Время: {{ $now }} (МСК)
|
||||
|
||||
Описание:
|
||||
{{ $summary }}
|
||||
|
||||
Это автоматическое сообщение от команды incidents:watch-failures (Лидерра).
|
||||
Проверьте incidents_log в панели администратора для деталей.
|
||||
@@ -0,0 +1,22 @@
|
||||
Scheduler Heartbeat: ПРОПАВШАЯ ЗАДАЧА
|
||||
======================================
|
||||
Команда: {{ $commandName }}
|
||||
Причина: {{ $reason }}
|
||||
Consecutive ошибок: {{ $consecutiveFailures }}
|
||||
Время: {{ $now }} (МСК)
|
||||
|
||||
@if($lastError)
|
||||
Последняя ошибка:
|
||||
{{ $lastError }}
|
||||
|
||||
@endif
|
||||
ВНИМАНИЕ: cron-задача {{ $commandName }} не даёт пульса.
|
||||
Это может означать, что планировщик Laravel не работает, команда зависла,
|
||||
или процесс завершается с ошибкой.
|
||||
|
||||
Необходимо:
|
||||
1. Проверить scheduler_heartbeats через админку или вручную.
|
||||
2. Проверить логи: storage/logs/laravel.log на сервере.
|
||||
3. Убедиться, что crontab/supervisor работает на боевом сервере.
|
||||
|
||||
Это автоматическое сообщение от команды scheduler:check-heartbeats (Лидерра).
|
||||
+70
-10
@@ -4,6 +4,7 @@ use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
@@ -12,6 +13,12 @@ Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// Hole #6: heartbeat-трекинг всех cron-задач.
|
||||
// SchedulerHeartbeatTracker::recordRun() оборачивает каждую задачу через
|
||||
// before/after/onFailure хуки Laravel Scheduler — минимально инвазивный подход.
|
||||
/** @var SchedulerHeartbeatTracker $hb */
|
||||
$hb = app(SchedulerHeartbeatTracker::class);
|
||||
|
||||
// Spec §6.1: ежедневный сброс projects.delivered_today=0 в 00:00 МСК.
|
||||
// delivered_in_month НЕ трогаем — это месячный счётчик, отдельный cron Plan 4.
|
||||
//
|
||||
@@ -21,18 +28,41 @@ Artisan::command('inspire', function () {
|
||||
// schema.sql нет (Laravel-default-миграции удалены, см. project_state.md фаза 1).
|
||||
Schedule::command('projects:reset-delivered-today')
|
||||
->dailyAt('00:00')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->before(fn () => $startTimes['projects:reset-delivered-today'] = microtime(true))
|
||||
->onSuccess(function () use ($hb, &$startTimes): void {
|
||||
$name = 'projects:reset-delivered-today';
|
||||
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
|
||||
$hb->recordRunResult($name, true, null, $ms);
|
||||
})
|
||||
->onFailure(function () use ($hb): void {
|
||||
$hb->recordRunResult('projects:reset-delivered-today', false, 'Command failed', null);
|
||||
});
|
||||
|
||||
// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService.
|
||||
Schedule::command('projects:reset-monthly')
|
||||
->monthlyOn(1, '00:00')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('projects:reset-monthly', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('projects:reset-monthly', false, 'Command failed', null));
|
||||
|
||||
// Audit #2 Phase 14 P2: partition maintenance — создаёт разделы на 3 месяца вперёд.
|
||||
// Без этой записи partitions:create-months не запускается автоматически.
|
||||
Schedule::command('partitions:create-months')
|
||||
->daily()
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('partitions:create-months', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('partitions:create-months', false, 'Command failed', null));
|
||||
|
||||
// Hole #2 (23.05.2026): удаление устаревших месячных партиций согласно retention
|
||||
// (system_settings: partition_retention_months_<table>).
|
||||
// Запускается еженедельно в воскресенье в 03:00 МСК — вне пиковых часов,
|
||||
// но раз в неделю достаточно (данные удаляются целыми месяцами).
|
||||
Schedule::command('partitions:drop-expired')
|
||||
->weeklyOn(0, '03:00')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
|
||||
|
||||
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
|
||||
//
|
||||
@@ -41,28 +71,58 @@ Schedule::command('partitions:create-months')
|
||||
// делает diff'ы (skip-no-diff), CleanupJob — UPDATE WHERE conditions, RefreshSession
|
||||
// — Cache::lock guard внутри handle, RetryFailedSupplierJobs — WHERE retried_at
|
||||
// фильтр. На multi-server prod может потребовать cache_locks таблицу.
|
||||
Schedule::job(new RefreshSupplierSessionJob)->hourly();
|
||||
Schedule::job(new RefreshSupplierSessionJob)->hourly()
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', false, 'Job failed', null));
|
||||
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
|
||||
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
|
||||
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время до
|
||||
// портального дедлайна 21:00. Session refresh — на 15 мин раньше sync (17:45).
|
||||
Schedule::job(new RefreshSupplierSessionJob)
|
||||
->dailyAt('17:45')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
|
||||
Schedule::job(new SyncSupplierProjectsJob)
|
||||
->dailyAt('18:00')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
|
||||
Schedule::job(new CleanupInactiveSupplierProjectsJob)
|
||||
->dailyAt('02:00')
|
||||
->timezone('Europe/Moscow');
|
||||
Schedule::command('supplier:retry-failed')->hourly();
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', false, 'Job failed', null));
|
||||
Schedule::command('supplier:retry-failed')->hourly()
|
||||
->onSuccess(fn () => $hb->recordRunResult('supplier:retry-failed', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('supplier:retry-failed', false, 'Command failed', null));
|
||||
|
||||
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
|
||||
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
|
||||
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes();
|
||||
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes()
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null));
|
||||
|
||||
// Audit #2 Phase 14 P2: авто-детекция штормов упавших webhook-джобов.
|
||||
// Сканирует за последние 10 мин, порог 200, дедуп 60 мин.
|
||||
Schedule::command('incidents:watch-failures')
|
||||
->everyTenMinutes()
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('incidents:watch-failures', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('incidents:watch-failures', false, 'Command failed', null));
|
||||
|
||||
// Hole #1: ежедневная проверка SHA-256 hash-chain в 6 audit-таблицах.
|
||||
// Разрыв → incidents_log (severity high) + email kdv1@bk.ru.
|
||||
// Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
|
||||
Schedule::command('audit:verify-chains')
|
||||
->dailyAt('04:00')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('audit:verify-chains', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('audit:verify-chains', false, 'Command failed', null));
|
||||
|
||||
// Hole #6: проверка пульса планировщика — hourly МСК.
|
||||
Schedule::command('scheduler:check-heartbeats')
|
||||
->hourly()
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
|
||||
|
||||
@@ -162,6 +162,16 @@ Route::middleware('saas-admin')->group(function () {
|
||||
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
|
||||
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
|
||||
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
|
||||
|
||||
// 152-ФЗ: обращения субъектов ПДн + анонимизация (дыра #4).
|
||||
Route::prefix('/api/admin/pd-subject-requests')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@index');
|
||||
Route::post('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@store');
|
||||
Route::get('/{id}', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@show')
|
||||
->where('id', '[0-9]+');
|
||||
Route::post('/{id}/erase', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@executeErasure')
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdErasureService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Создать stub saas_admin_users и вернуть его id. */
|
||||
function pdStubAdminUser(string $email = 'pd-test-stub@system.local'): int
|
||||
{
|
||||
$existing = DB::table('saas_admin_users')->where('email', $email)->value('id');
|
||||
if ($existing !== null) {
|
||||
return (int) $existing;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => $email,
|
||||
'full_name' => 'PD Test Stub',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Создать тенант и вернуть его. */
|
||||
function pdCreateTenant(): Tenant
|
||||
{
|
||||
return Tenant::factory()->create([
|
||||
'subdomain' => 'pd-test-'.uniqid(),
|
||||
'organization_name' => 'PD Test Org',
|
||||
'contact_email' => 'pd-tenant@test.local',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Вставить запись pd_subject_requests напрямую и вернуть id. */
|
||||
function pdInsertRequest(array $attrs = []): int
|
||||
{
|
||||
$defaults = [
|
||||
'received_at' => now(),
|
||||
'subject_email' => 'subject@example.com',
|
||||
'subject_phone' => null,
|
||||
'subject_full_name' => 'Test Subject',
|
||||
'request_type' => 'deletion',
|
||||
'description' => 'Test description',
|
||||
'status' => 'received',
|
||||
'tenant_id' => null,
|
||||
'processing_restricted' => false,
|
||||
// deadline_at заполняется триггером, но NOT NULL — вставим вручную
|
||||
'deadline_at' => now()->addDays(30),
|
||||
];
|
||||
|
||||
return (int) DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->insertGetId(array_merge($defaults, $attrs));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('index returns paginated list of pd_subject_requests', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
// Вставим 2 записи
|
||||
pdInsertRequest(['request_type' => 'deletion']);
|
||||
pdInsertRequest(['request_type' => 'access', 'status' => 'completed']);
|
||||
|
||||
$response = $this->getJson('/api/admin/pd-subject-requests');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('total'))->toBeGreaterThanOrEqual(2);
|
||||
expect($response->json('data'))->toBeArray();
|
||||
$first = $response->json('data.0');
|
||||
expect($first)->toHaveKeys(['id', 'received_at', 'request_type', 'status', 'deadline_at']);
|
||||
});
|
||||
|
||||
it('index filters by status', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
pdInsertRequest(['status' => 'received']);
|
||||
pdInsertRequest(['status' => 'completed', 'request_type' => 'access']);
|
||||
|
||||
$response = $this->getJson('/api/admin/pd-subject-requests?status=received');
|
||||
|
||||
$response->assertOk();
|
||||
foreach ($response->json('data') as $row) {
|
||||
expect($row['status'])->toBe('received');
|
||||
}
|
||||
});
|
||||
|
||||
it('store creates pd_subject_request with deadline_at ~+30 days from received_at', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$response = $this->postJson('/api/admin/pd-subject-requests', [
|
||||
'subject_email' => 'newsubject@example.com',
|
||||
'request_type' => 'deletion',
|
||||
'description' => 'Please delete my data.',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$data = $response->json('data');
|
||||
expect($data['subject_email'])->toBe('newsubject@example.com');
|
||||
expect($data['request_type'])->toBe('deletion');
|
||||
expect($data['status'])->toBe('received');
|
||||
|
||||
// deadline_at должен быть ~30 дней вперёд (с погрешностью ±2 дня на тест-лаги)
|
||||
$deadline = CarbonImmutable::parse($data['deadline_at']);
|
||||
$received = CarbonImmutable::parse($data['received_at']);
|
||||
// diffInDays: абсолютное значение (порядок параметров не важен с abs)
|
||||
$diff = abs($deadline->diffInDays($received));
|
||||
expect($diff)->toBeGreaterThanOrEqual(29)->toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
it('store validates: at least email or phone required', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson('/api/admin/pd-subject-requests', [
|
||||
'request_type' => 'deletion',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('store validates: request_type must be valid', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson('/api/admin/pd-subject-requests', [
|
||||
'subject_email' => 'x@y.com',
|
||||
'request_type' => 'invalid_type',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('executeErasure anonymises user email first_name phone and writes pd_processing_log', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
// Используем pgsql_supplier для всех вставок, чтобы FK-проверки работали
|
||||
// в рамках одного соединения (DatabaseTransactions оборачивает default pgsql,
|
||||
// но pgsql_supplier видит только committed данные default-соединения).
|
||||
$stubEmail = 'pd-user-stub-'.uniqid().'@system.local';
|
||||
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
|
||||
'email' => $stubEmail,
|
||||
'full_name' => 'User Test Stub',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
|
||||
// Создаём тенант через pgsql_supplier (тот же физ. сервер/БД)
|
||||
$tenantId = (int) DB::connection('pgsql_supplier')->table('tenants')->insertGetId([
|
||||
'subdomain' => 'pd-user-test-'.uniqid(),
|
||||
'organization_name' => 'PD User Test',
|
||||
'contact_email' => 'pd-u@test.local',
|
||||
'status' => 'active',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'balance_rub' => '0.00',
|
||||
'balance_leads' => 0,
|
||||
'is_trial' => false,
|
||||
'chargeback_unrecovered_rub' => '0.00',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Создаём user с email/phone субъекта
|
||||
$victimEmail = 'victim-'.uniqid().'@example.com';
|
||||
$victimPhone = '+79991234567';
|
||||
|
||||
$userId = (int) DB::connection('pgsql_supplier')->table('users')->insertGetId([
|
||||
'tenant_id' => $tenantId,
|
||||
'email' => $victimEmail,
|
||||
'password_hash' => '$2y$04$test',
|
||||
'first_name' => 'Иван',
|
||||
'last_name' => 'Иванов',
|
||||
'phone' => $victimPhone,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => $victimEmail,
|
||||
'subject_phone' => $victimPhone,
|
||||
'tenant_id' => $tenantId,
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
||||
'admin_user_id' => $adminId,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('counts.users'))->toBe(1);
|
||||
|
||||
// Проверяем анонимизацию user
|
||||
$user = DB::connection('pgsql_supplier')->table('users')->where('id', $userId)->first();
|
||||
expect($user->email)->toContain('erased-');
|
||||
expect($user->first_name)->toBe('Удалено');
|
||||
expect($user->phone)->toContain('+7000');
|
||||
|
||||
// pd_processing_log должен содержать запись
|
||||
$log = DB::connection('pgsql_supplier')
|
||||
->table('pd_processing_log')
|
||||
->where('subject_id', $userId)
|
||||
->where('subject_type', 'user')
|
||||
->where('action', 'deleted')
|
||||
->where('actor_admin_user_id', $adminId)
|
||||
->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->purpose)->toBe('152-FZ erasure');
|
||||
});
|
||||
|
||||
it('executeErasure anonymises supplier_lead phone and raw_payload', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
// Создаём stub admin через pgsql_supplier, чтобы FK pd_processing_log работал
|
||||
// независимо от DatabaseTransactions-транзакции default-соединения.
|
||||
$stubEmail = 'pd-lead-stub-'.uniqid().'@system.local';
|
||||
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
|
||||
'email' => $stubEmail,
|
||||
'full_name' => 'Lead Test Stub',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
|
||||
$victimPhone = '+79887654321';
|
||||
|
||||
$leadId = (int) DB::connection('pgsql_supplier')->table('supplier_leads')->insertGetId([
|
||||
'platform' => 'B1',
|
||||
'raw_payload' => json_encode(['phone' => $victimPhone, 'name' => 'Жертва']),
|
||||
'phone' => $victimPhone,
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_phone' => $victimPhone,
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
||||
'admin_user_id' => $adminId,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('counts.leads'))->toBeGreaterThanOrEqual(1);
|
||||
|
||||
$lead = DB::connection('pgsql_supplier')->table('supplier_leads')->where('id', $leadId)->first();
|
||||
expect($lead->phone)->toBe('+7000XXXXXXX');
|
||||
$payload = json_decode($lead->raw_payload, true);
|
||||
expect($payload['erased'])->toBe(true);
|
||||
});
|
||||
|
||||
it('executeErasure marks pd_subject_request as completed', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$adminId = pdStubAdminUser();
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => 'mark-completed-'.uniqid().'@example.com',
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
||||
'admin_user_id' => $adminId,
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $requestId)
|
||||
->first();
|
||||
|
||||
expect($row->status)->toBe('completed');
|
||||
expect($row->completed_at)->not->toBeNull();
|
||||
expect($row->response_text)->toContain('users=');
|
||||
});
|
||||
|
||||
it('executeErasure rejects non-deletion request_type with 422', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => 'access-request@example.com',
|
||||
'request_type' => 'access',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('executeErasure rejects already completed request with 422', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => 'already-done-'.uniqid().'@example.com',
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('saas-admin middleware allows request in testing env', function (): void {
|
||||
// EnsureSaasAdmin в testing-окружении пропускает всех без проверки.
|
||||
$response = $this->getJson('/api/admin/pd-subject-requests');
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
it('PdErasureService throws InvalidArgumentException when both email and phone are null', function (): void {
|
||||
$service = app(PdErasureService::class);
|
||||
|
||||
expect(fn () => $service->eraseSubject(null, null, null, 1, null))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
@@ -11,8 +11,10 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Full operational journaling pipeline integration test.
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
// Helper: insert a failed_webhook_jobs row
|
||||
function makeFailedWebhookJob(string $exception, ?DateTimeInterface $at = null): void
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard: check whether auth_log is partitioned. Tests in this file require
|
||||
// the partition_audit_tables migration to have run (hole #2, 23.05.2026).
|
||||
// If it hasn't been applied yet, every CREATE TABLE ... PARTITION OF call will
|
||||
// fail with "relation is not partitioned". We detect this once and skip.
|
||||
// ---------------------------------------------------------------------------
|
||||
function authLogIsPartitioned(): bool
|
||||
{
|
||||
return DB::selectOne(
|
||||
"SELECT 1 AS ok
|
||||
FROM pg_class c
|
||||
JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
|
||||
WHERE c.relname = 'auth_log'",
|
||||
) !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: set or remove partition retention in system_settings.
|
||||
// ---------------------------------------------------------------------------
|
||||
function setRetention(string $table, ?int $months): void
|
||||
{
|
||||
$key = "partition_retention_months_{$table}";
|
||||
|
||||
if ($months === null) {
|
||||
DB::table('system_settings')->where('key', $key)->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('system_settings')->upsert(
|
||||
[
|
||||
'key' => $key,
|
||||
'value' => (string) $months,
|
||||
'type' => 'int',
|
||||
'description' => "Test retention for {$table}",
|
||||
'updated_at' => now(),
|
||||
],
|
||||
['key'],
|
||||
['value', 'updated_at'],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a test partition for a given table and month.
|
||||
// Returns partition name (e.g. auth_log_y2026_m02).
|
||||
// ---------------------------------------------------------------------------
|
||||
function createTestPartition(string $table, Carbon $monthStart): string
|
||||
{
|
||||
/** @var MonthlyPartitionManager $mgr */
|
||||
$mgr = app(MonthlyPartitionManager::class);
|
||||
$mgr->ensureMonth($table, $monthStart);
|
||||
|
||||
return $mgr->partitionName($table, $monthStart);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: check whether a partition physically exists in pg_class.
|
||||
// Named with "Drop" prefix to avoid collision with MonthlyPartitionManagerTest.
|
||||
// ---------------------------------------------------------------------------
|
||||
function dropExpiredPartitionExists(string $partitionName): bool
|
||||
{
|
||||
return DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
[$partitionName],
|
||||
) !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared teardown: reset Carbon fake time after each test.
|
||||
// ---------------------------------------------------------------------------
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow(null);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
test('dry-run: partition is not dropped', function () {
|
||||
Carbon::setTestNow('2026-05-15');
|
||||
|
||||
$oldMonth = Carbon::create(2026, 2, 1)->startOfMonth(); // 3 months ago
|
||||
$partition = createTestPartition('auth_log', $oldMonth);
|
||||
setRetention('auth_log', 1); // cutoff = 2026-04, so 2026-02 would normally drop
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue('Partition must exist before command');
|
||||
|
||||
$this->artisan('partitions:drop-expired --dry-run')->assertSuccessful();
|
||||
|
||||
// Dry-run never physically drops
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue('Dry-run must NOT drop the partition');
|
||||
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
||||
|
||||
test('drops partition older than retention boundary', function () {
|
||||
Carbon::setTestNow('2026-05-15');
|
||||
|
||||
// retention=2: cutoff = 2026-03; 2026-02 is strictly older → drop
|
||||
$oldMonth = Carbon::create(2026, 2, 1)->startOfMonth();
|
||||
$partition = createTestPartition('auth_log', $oldMonth);
|
||||
setRetention('auth_log', 2);
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
||||
|
||||
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeFalse('Partition beyond retention must be dropped');
|
||||
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
||||
|
||||
test('does not drop partition at the retention boundary (inclusive keep)', function () {
|
||||
Carbon::setTestNow('2026-05-15');
|
||||
|
||||
// retention=3: cutoff = 2026-02; 2026-02 is NOT strictly less than cutoff → keep
|
||||
$boundaryMonth = Carbon::create(2026, 2, 1)->startOfMonth();
|
||||
$partition = createTestPartition('auth_log', $boundaryMonth);
|
||||
setRetention('auth_log', 3);
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
||||
|
||||
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue('Partition at boundary must NOT be dropped');
|
||||
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
||||
|
||||
test('skips table when retention is not configured', function () {
|
||||
Carbon::setTestNow('2026-05-15');
|
||||
|
||||
setRetention('auth_log', null); // remove any retention setting
|
||||
|
||||
$oldMonth = Carbon::create(2025, 1, 1)->startOfMonth();
|
||||
$partition = createTestPartition('auth_log', $oldMonth);
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
||||
|
||||
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue('No retention config → nothing dropped');
|
||||
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
||||
|
||||
test('skips table when retention value is 0 (safety guard)', function () {
|
||||
Carbon::setTestNow('2026-05-15');
|
||||
|
||||
$oldMonth = Carbon::create(2024, 1, 1)->startOfMonth();
|
||||
$partition = createTestPartition('auth_log', $oldMonth);
|
||||
setRetention('auth_log', 0);
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue();
|
||||
|
||||
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
||||
|
||||
expect(dropExpiredPartitionExists($partition))->toBeTrue('retention=0 must be blocked — nothing dropped');
|
||||
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
||||
|
||||
test('keeps recent partitions, drops only expired ones', function () {
|
||||
Carbon::setTestNow('2026-05-15');
|
||||
|
||||
// retention=2 → cutoff = 2026-03
|
||||
// keep: 2026-05 (current), 2026-04 (1mo ago), 2026-03 (boundary)
|
||||
// drop: 2026-02 (3mo ago), 2026-01 (4mo ago)
|
||||
setRetention('auth_log', 2);
|
||||
|
||||
$keep1 = createTestPartition('auth_log', Carbon::create(2026, 5, 1));
|
||||
$keep2 = createTestPartition('auth_log', Carbon::create(2026, 4, 1));
|
||||
$keep3 = createTestPartition('auth_log', Carbon::create(2026, 3, 1));
|
||||
$drop1 = createTestPartition('auth_log', Carbon::create(2026, 2, 1));
|
||||
$drop2 = createTestPartition('auth_log', Carbon::create(2026, 1, 1));
|
||||
|
||||
$this->artisan('partitions:drop-expired')->assertSuccessful();
|
||||
|
||||
expect(dropExpiredPartitionExists($keep1))->toBeTrue('2026-05 must be kept (current)');
|
||||
expect(dropExpiredPartitionExists($keep2))->toBeTrue('2026-04 must be kept (within retention)');
|
||||
expect(dropExpiredPartitionExists($keep3))->toBeTrue('2026-03 must be kept (at boundary)');
|
||||
expect(dropExpiredPartitionExists($drop1))->toBeFalse('2026-02 must be dropped');
|
||||
expect(dropExpiredPartitionExists($drop2))->toBeFalse('2026-01 must be dropped');
|
||||
})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)');
|
||||
@@ -0,0 +1,431 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\AuditChainBreachMail;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: ensure at least one active saas_admin_user row exists (FK for incidents_log)
|
||||
// ---------------------------------------------------------------------------
|
||||
function ensureAuditAdmin(): int
|
||||
{
|
||||
$id = DB::table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($id !== null) {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'audit-cron@liderra.ru',
|
||||
'full_name' => 'Audit Cron',
|
||||
'password_hash' => '$2y$12$placeholder',
|
||||
'role' => 'dev_oncall',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: insert N rows into auth_log via the normal path (trigger fills log_hash).
|
||||
// Uses the 3rd constraint variant: actor_type='tenant_user', user_id=NULL,
|
||||
// saas_admin_user_id=NULL, email IS NOT NULL (login attempt with unknown email).
|
||||
// ---------------------------------------------------------------------------
|
||||
function insertAuthLogRows(int $n, ?int $tenantId = null): void
|
||||
{
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
DB::table('auth_log')->insert([
|
||||
'actor_type' => 'tenant_user',
|
||||
'tenant_id' => $tenantId,
|
||||
'email' => "test{$i}@example.com",
|
||||
'event' => 'login_failed',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: ensure a tenant row exists, return its id.
|
||||
// ---------------------------------------------------------------------------
|
||||
function ensureTenant(int $seed): int
|
||||
{
|
||||
$existing = DB::table('tenants')->where('subdomain', "test-chain-{$seed}")->value('id');
|
||||
if ($existing !== null) {
|
||||
return (int) $existing;
|
||||
}
|
||||
|
||||
return (int) DB::table('tenants')->insertGetId([
|
||||
'organization_name' => "Test Chain {$seed}",
|
||||
'subdomain' => "test-chain-{$seed}",
|
||||
'contact_email' => "chain{$seed}@example.com",
|
||||
'webhook_token' => bin2hex(random_bytes(16))."-seed{$seed}",
|
||||
'status' => 'active',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: insert one row into tenant_operations_log under a specific tenant.
|
||||
// The trigger fills log_hash based on what it can SELECT (see notes below).
|
||||
// ---------------------------------------------------------------------------
|
||||
function insertTenantOpsRow(int $tenantId, string $event = 'project.created'): int
|
||||
{
|
||||
return (int) DB::table('tenant_operations_log')->insertGetId([
|
||||
'tenant_id' => $tenantId,
|
||||
'entity_type' => 'project',
|
||||
'entity_id' => 1,
|
||||
'event' => $event,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build per-tenant chained hashes in SQL (mirrors the validator logic).
|
||||
//
|
||||
// This computes what the per-scope validator expects: for each row in
|
||||
// tenant_operations_log, prev_hash = LAG(log_hash) OVER (PARTITION BY tenant_id
|
||||
// ORDER BY id). We use this to manually seed correctly-chained rows when we
|
||||
// need to bypass the trigger (which on dev/superuser chains globally).
|
||||
//
|
||||
// Returns array of ['id' => int, 'hash' => resource] sorted by id.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
beforeEach(function () {
|
||||
ensureAuditAdmin();
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// TDD ANCHOR: clean chain must verify intact (serialization correctness gate)
|
||||
// ===========================================================================
|
||||
|
||||
test('clean auth_log chain verifies intact', function () {
|
||||
insertAuthLogRows(3);
|
||||
|
||||
$exitCode = Artisan::call('audit:verify-chains');
|
||||
$out = Artisan::output();
|
||||
if ($exitCode !== 0) {
|
||||
dump('OUTPUT:', $out);
|
||||
}
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
// No incident should be created for an intact chain
|
||||
$count = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('summary', 'like', '%chain%auth_log%')
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
|
||||
// No email should be sent
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('empty tables are skipped gracefully (no false positive)', function () {
|
||||
// auth_log might have rows from other tests but other tables are empty on dev.
|
||||
// The command must not raise an error on empty tables.
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('multiple rows in auth_log all pass intact', function () {
|
||||
insertAuthLogRows(10);
|
||||
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// MULTI-TENANT REGRESSION: per-scope validator must not false-positive on
|
||||
// data with multiple tenants.
|
||||
//
|
||||
// Problem reproduced on prod: the old global validator computed LAG OVER
|
||||
// (ORDER BY id) across ALL tenants. When tenant B's first row followed
|
||||
// tenant A's last row, its stored_hash was SHA256(A_last.log_hash || B_row),
|
||||
// but the global recompute gave the same thing — so it was fine globally.
|
||||
// However, on PROD (crm_app_user, NOT BYPASSRLS) the trigger's SELECT only
|
||||
// sees the current tenant's rows, so B_row[0] is chained off '' (no prev),
|
||||
// not off A_last. The global validator then reported a false breach at every
|
||||
// tenant boundary.
|
||||
//
|
||||
// This test replicates PROD conditions by bypassing the trigger and manually
|
||||
// inserting rows with CORRECTLY per-tenant-chained hashes (what the trigger
|
||||
// would produce under RLS on prod). The per-scope validator must then report
|
||||
// INTACT, confirming the partition logic is correct.
|
||||
//
|
||||
// Why bypass the trigger for this test:
|
||||
// On dev (postgres = superuser), the trigger's SELECT has no RLS filter and
|
||||
// sees ALL rows globally. Inserting via the trigger would produce a GLOBAL
|
||||
// chain regardless of the app.current_tenant_id GUC. To test the validator's
|
||||
// per-partition recompute, we need rows whose stored hashes were computed
|
||||
// with per-tenant prev_hash — exactly what the trigger produces on prod.
|
||||
// We reproduce that by disabling triggers and computing the hashes ourselves
|
||||
// using the same digest(COALESCE(prev,'')||row::text,'sha256') formula with
|
||||
// per-partition LAG, matching the validator's own recompute SQL.
|
||||
// ===========================================================================
|
||||
|
||||
test('per-tenant chained tenant_operations_log validates intact (prod-behaviour regression)', function () {
|
||||
$tid1 = ensureTenant(1);
|
||||
$tid2 = ensureTenant(2);
|
||||
|
||||
// Disable triggers so we can insert rows with manually computed hashes
|
||||
// that simulate per-tenant chaining (what the trigger produces on prod).
|
||||
DB::statement('ALTER TABLE tenant_operations_log DISABLE TRIGGER USER');
|
||||
|
||||
try {
|
||||
// Insert 3 rows for tenant 1 and 2 rows for tenant 2, interleaved by id.
|
||||
// We insert WITHOUT log_hash first, then update with per-tenant-correct hashes.
|
||||
// (INSERT with log_hash=NULL, then fill via SQL digest to avoid PHP bytea handling.)
|
||||
|
||||
$rows = [
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 10, 'event' => 'project.created', 'created_at' => now()],
|
||||
['tenant_id' => $tid2, 'entity_type' => 'api_key', 'entity_id' => 20, 'event' => 'api_key.regenerated', 'created_at' => now()],
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 11, 'event' => 'project.updated', 'created_at' => now()],
|
||||
['tenant_id' => $tid2, 'entity_type' => 'project', 'entity_id' => 21, 'event' => 'project.created', 'created_at' => now()],
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 12, 'event' => 'project.deleted', 'created_at' => now()],
|
||||
];
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
// log_hash left NULL; we will fill it below via SQL
|
||||
$ids[] = (int) DB::table('tenant_operations_log')->insertGetId($row);
|
||||
}
|
||||
|
||||
// Now fill log_hash for each inserted row using per-tenant-partitioned prev_hash,
|
||||
// mirroring exactly what the trigger produces under RLS on prod:
|
||||
// log_hash = digest(COALESCE(prev_tenant_hash, ''::bytea) || ROW(...)::text::bytea, 'sha256')
|
||||
// where prev_tenant_hash = last log_hash of the SAME tenant ordered by id.
|
||||
//
|
||||
// We do this in id-order one row at a time so each hash feeds the next.
|
||||
$idList = implode(',', $ids);
|
||||
|
||||
DB::statement(<<<SQL
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
ROW(id, tenant_id, user_id, entity_type, entity_id, event,
|
||||
payload_before, payload_after, ip_address, user_agent,
|
||||
NULL::bytea, created_at) AS row_val,
|
||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) AS rn
|
||||
FROM tenant_operations_log
|
||||
WHERE id IN ({$idList})
|
||||
)
|
||||
UPDATE tenant_operations_log tgt
|
||||
SET log_hash = (
|
||||
WITH RECURSIVE chain(id, tenant_id, rn, hash) AS (
|
||||
-- Base: first row per tenant (rn=1), prev_hash = ''
|
||||
SELECT r.id, r.tenant_id, r.rn,
|
||||
digest(''::bytea || r.row_val::text::bytea, 'sha256')
|
||||
FROM ranked r
|
||||
WHERE r.rn = 1
|
||||
UNION ALL
|
||||
-- Recursive: each subsequent row chains off previous
|
||||
SELECT r.id, r.tenant_id, r.rn,
|
||||
digest(c.hash || r.row_val::text::bytea, 'sha256')
|
||||
FROM chain c
|
||||
JOIN ranked r ON r.tenant_id = c.tenant_id AND r.rn = c.rn + 1
|
||||
)
|
||||
SELECT hash FROM chain WHERE id = tgt.id
|
||||
)
|
||||
WHERE id IN ({$idList})
|
||||
SQL);
|
||||
} finally {
|
||||
DB::statement('ALTER TABLE tenant_operations_log ENABLE TRIGGER USER');
|
||||
}
|
||||
|
||||
// Verify all 5 rows got their hashes set (sanity check on the SQL above)
|
||||
$nullCount = DB::table('tenant_operations_log')
|
||||
->whereIn('id', $ids)
|
||||
->whereNull('log_hash')
|
||||
->count();
|
||||
expect($nullCount)->toBe(0, 'All inserted rows must have log_hash set by the chain SQL');
|
||||
|
||||
// The per-scope validator must report INTACT — no false breach at tenant boundary
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tampering detection
|
||||
// ===========================================================================
|
||||
|
||||
test('tampered auth_log row raises incident and sends email', function () {
|
||||
insertAuthLogRows(3);
|
||||
|
||||
// Sanity: intact before tampering
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
Mail::assertNothingSent();
|
||||
|
||||
// Tamper: disable triggers, mutate the first row's ip_address, re-enable.
|
||||
// DISABLE TRIGGER USER disables all user-defined triggers (audit_block_mutation
|
||||
// is BEFORE UPDATE, so without disabling it the UPDATE would be blocked).
|
||||
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
|
||||
DB::table('auth_log')
|
||||
->orderBy('id')
|
||||
->limit(1)
|
||||
->update(['ip_address' => '6.6.6.6']);
|
||||
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
|
||||
|
||||
// Now the command must detect the breach
|
||||
$this->artisan('audit:verify-chains')->assertFailed();
|
||||
|
||||
$incident = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('type', 'other')
|
||||
->where('severity', 'high')
|
||||
->where('summary', 'like', '%chain%auth_log%')
|
||||
->first();
|
||||
|
||||
expect($incident)->not->toBeNull();
|
||||
expect($incident->summary)->toContain('auth_log');
|
||||
|
||||
Mail::assertSent(AuditChainBreachMail::class, function ($mail) {
|
||||
return $mail->tableName === 'auth_log';
|
||||
});
|
||||
});
|
||||
|
||||
test('incident dedup: same table breach does not create duplicate within 24h', function () {
|
||||
insertAuthLogRows(3);
|
||||
|
||||
// First tamper
|
||||
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
|
||||
DB::table('auth_log')
|
||||
->orderBy('id')
|
||||
->limit(1)
|
||||
->update(['ip_address' => '5.5.5.5']);
|
||||
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
|
||||
|
||||
$this->artisan('audit:verify-chains')->assertFailed();
|
||||
|
||||
$countAfterFirst = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('summary', 'like', '%chain%auth_log%')
|
||||
->count();
|
||||
expect($countAfterFirst)->toBe(1);
|
||||
|
||||
// Second run (same ongoing breach) must NOT create a second incident (dedup)
|
||||
$this->artisan('audit:verify-chains')->assertFailed();
|
||||
|
||||
$countAfterSecond = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('summary', 'like', '%chain%auth_log%')
|
||||
->count();
|
||||
expect($countAfterSecond)->toBe(1);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// PARTITION-AWARE: breach in one partition must record incident with
|
||||
// partition_name; other partitions must remain intact (no false positive).
|
||||
// ===========================================================================
|
||||
|
||||
test('breach in one partition is detected; other partitions reported intact', function () {
|
||||
// Insert 3 rows into auth_log — trigger assigns log_hash correctly.
|
||||
// After the migration, auth_log is partitioned by month; all test rows
|
||||
// go into the current month's partition (e.g. auth_log_y2026_m05).
|
||||
insertAuthLogRows(3);
|
||||
|
||||
// Sanity: chain intact before tampering.
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
Mail::assertNothingSent();
|
||||
|
||||
// Determine which partition the inserted rows landed in, so we can assert
|
||||
// the incident summary references that partition, not just "auth_log".
|
||||
//
|
||||
// Rows are inserted with created_at = now(), so they land in the partition
|
||||
// whose range covers the current month. We derive the expected name the
|
||||
// same way buildPartitionDDL() does: {table}_y{YYYY}_m{MM}.
|
||||
//
|
||||
// Fallback: if auth_log has no children (migration not applied), we expect
|
||||
// the incident to reference 'auth_log' itself (command's fallback path).
|
||||
$partitionCount = DB::selectOne(
|
||||
"SELECT COUNT(*) AS cnt
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = 'auth_log'",
|
||||
)->cnt ?? 0;
|
||||
|
||||
$expectedPartition = $partitionCount > 0
|
||||
? sprintf('auth_log_y%s_m%s', now()->format('Y'), now()->format('m'))
|
||||
: 'auth_log';
|
||||
|
||||
// Tamper the first row in auth_log (which lands in $expectedPartition).
|
||||
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
|
||||
DB::table('auth_log')
|
||||
->orderBy('id')
|
||||
->limit(1)
|
||||
->update(['ip_address' => '7.7.7.7']);
|
||||
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
|
||||
|
||||
// Command must fail and reference the partition in the incident summary.
|
||||
$this->artisan('audit:verify-chains')->assertFailed();
|
||||
|
||||
$incident = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('type', 'other')
|
||||
->where('severity', 'high')
|
||||
->where('summary', 'like', '%chain%auth_log%')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
expect($incident)->not->toBeNull('An incident must be recorded on chain breach');
|
||||
// The incident summary must mention the specific partition (or the table if not yet partitioned).
|
||||
expect($incident->summary)->toContain($expectedPartition);
|
||||
|
||||
// Email must be sent referencing the correct partition.
|
||||
Mail::assertSent(AuditChainBreachMail::class, function ($mail) use ($expectedPartition) {
|
||||
// partitionName is the 4th constructor arg (nullable); falls back to tableName.
|
||||
$actualPartition = $mail->partitionName ?? $mail->tableName;
|
||||
|
||||
return $actualPartition === $expectedPartition && $mail->tableName === 'auth_log';
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// EXIT CODE: breach must always return FAILURE regardless of incident write
|
||||
// ===========================================================================
|
||||
|
||||
test('exit code is FAILURE on breach even when no active admin exists for incident FK', function () {
|
||||
// Remove all active admins so recordIncident cannot write the FK row
|
||||
DB::table('saas_admin_users')->update(['is_active' => false]);
|
||||
|
||||
insertAuthLogRows(2);
|
||||
|
||||
// Tamper
|
||||
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
|
||||
DB::table('auth_log')
|
||||
->orderBy('id')
|
||||
->limit(1)
|
||||
->update(['ip_address' => '9.9.9.9']);
|
||||
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
|
||||
|
||||
// Must FAIL even though the incident row cannot be written (no active admin)
|
||||
$this->artisan('audit:verify-chains')->assertFailed();
|
||||
|
||||
// No incident row written (no active admin)
|
||||
$count = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('summary', 'like', '%chain%auth_log%')
|
||||
->count();
|
||||
expect($count)->toBe(0);
|
||||
|
||||
// But email alert must still be sent
|
||||
Mail::assertSent(AuditChainBreachMail::class);
|
||||
});
|
||||
@@ -17,6 +17,10 @@ function partitionExists(string $name): bool
|
||||
) !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Existing tests (deals — business table, received_at key)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('ensureRange создаёт месячные партиции deals под диапазон', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
|
||||
@@ -27,9 +31,9 @@ test('ensureRange создаёт месячные партиции deals под
|
||||
);
|
||||
|
||||
expect($created)->toBeGreaterThanOrEqual(3)
|
||||
->and(partitionExists('deals_2024_02'))->toBeTrue()
|
||||
->and(partitionExists('deals_2024_03'))->toBeTrue()
|
||||
->and(partitionExists('deals_2024_04'))->toBeTrue();
|
||||
->and(partitionExists('deals_y2024_m02'))->toBeTrue()
|
||||
->and(partitionExists('deals_y2024_m03'))->toBeTrue()
|
||||
->and(partitionExists('deals_y2024_m04'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureRange идемпотентна — повторный вызов не падает', function (): void {
|
||||
@@ -44,3 +48,79 @@ test('ensureRange идемпотентна — повторный вызов н
|
||||
test('ensureRange отвергает неизвестную таблицу', function (): void {
|
||||
app(MonthlyPartitionManager::class)->ensureRange('orders', now(), now());
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hole #2 tests: audit tables (created_at key)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('ensureMonth создаёт партицию auth_log (created_at)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$month = Carbon::parse('2024-03-01');
|
||||
|
||||
$manager->ensureMonth('auth_log', $month);
|
||||
|
||||
expect(partitionExists('auth_log_y2024_m03'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureMonth создаёт партицию activity_log (created_at)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('activity_log', Carbon::parse('2024-03-01'));
|
||||
|
||||
expect(partitionExists('activity_log_y2024_m03'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureMonth создаёт партицию tenant_operations_log (created_at)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('tenant_operations_log', Carbon::parse('2024-03-01'));
|
||||
|
||||
expect(partitionExists('tenant_operations_log_y2024_m03'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureMonth создаёт партицию webhook_log (received_at)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('webhook_log', Carbon::parse('2024-03-01'));
|
||||
|
||||
expect(partitionExists('webhook_log_y2024_m03'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureMonth создаёт партицию balance_transactions (created_at)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('balance_transactions', Carbon::parse('2024-03-01'));
|
||||
|
||||
expect(partitionExists('balance_transactions_y2024_m03'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureMonth создаёт партицию pd_processing_log (created_at)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('pd_processing_log', Carbon::parse('2024-03-01'));
|
||||
|
||||
expect(partitionExists('pd_processing_log_y2024_m03'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ensureMonth создаёт партицию saas_admin_audit_log (created_at)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('saas_admin_audit_log', Carbon::parse('2024-03-01'));
|
||||
|
||||
expect(partitionExists('saas_admin_audit_log_y2024_m03'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('partitionName возвращает правильный формат', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
|
||||
expect($manager->partitionName('auth_log', Carbon::parse('2026-05-15')))
|
||||
->toBe('auth_log_y2026_m05');
|
||||
|
||||
expect($manager->partitionName('deals', Carbon::parse('2024-01-01')))
|
||||
->toBe('deals_y2024_m01');
|
||||
});
|
||||
|
||||
test('listPartitions возвращает созданные партиции', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('auth_log', Carbon::parse('2024-04-01'));
|
||||
$manager->ensureMonth('auth_log', Carbon::parse('2024-05-01'));
|
||||
|
||||
$partitions = $manager->listPartitions('auth_log');
|
||||
|
||||
expect($partitions)->toContain('auth_log_y2024_m04')
|
||||
->toContain('auth_log_y2024_m05');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\IncidentDetectedMail;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFailedJob(string $jobClass, string $exception, ?Carbon $at = null): void
|
||||
{
|
||||
$payload = json_encode(['displayName' => $jobClass, 'job' => $jobClass]);
|
||||
DB::table('failed_jobs')->insert([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'connection' => 'redis',
|
||||
'queue' => 'default',
|
||||
'payload' => $payload,
|
||||
'exception' => $exception,
|
||||
'failed_at' => $at ?? now(),
|
||||
]);
|
||||
}
|
||||
|
||||
function makeFailedWebhookJobExp(string $exception, ?Carbon $at = null): void
|
||||
{
|
||||
DB::table('failed_webhook_jobs')->insert([
|
||||
'failed_at' => $at ?? now(),
|
||||
'exception' => $exception,
|
||||
'raw_payload' => '{}',
|
||||
'retry_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function ensureAdminExp(): int
|
||||
{
|
||||
$id = DB::table('saas_admin_users')->value('id');
|
||||
if ($id !== null) {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'cron-expanded@liderra.ru',
|
||||
'full_name' => 'Cron Expanded',
|
||||
'password_hash' => '$2y$12$placeholder',
|
||||
'role' => 'dev_oncall',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Setup ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
ensureAdminExp();
|
||||
});
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('failed_webhook_jobs spike still creates high incident (existing logic preserved)', function () {
|
||||
$now = Carbon::now();
|
||||
for ($i = 0; $i < 201; $i++) {
|
||||
makeFailedWebhookJobExp('App\\Exceptions\\WebhookException: connection refused', $now);
|
||||
}
|
||||
|
||||
$this->artisan('incidents:watch-failures')->assertSuccessful();
|
||||
|
||||
$incidents = DB::table('incidents_log')->get();
|
||||
expect($incidents)->toHaveCount(1);
|
||||
expect($incidents->first()->severity)->toBe('high');
|
||||
});
|
||||
|
||||
test('failed_jobs spike threshold creates incident severity=high and sends mail', function () {
|
||||
$now = Carbon::now();
|
||||
for ($i = 0; $i < 11; $i++) {
|
||||
makeFailedJob(
|
||||
'App\\Jobs\\SyncSupplierProjectsJob',
|
||||
'RuntimeException: connection timeout',
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
$this->artisan('incidents:watch-failures', ['--threshold-spike' => 10])->assertSuccessful();
|
||||
|
||||
$incidents = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%spike%')
|
||||
->get();
|
||||
|
||||
expect($incidents)->toHaveCount(1);
|
||||
expect($incidents->first()->severity)->toBe('high');
|
||||
|
||||
Mail::assertSent(IncidentDetectedMail::class, 1);
|
||||
});
|
||||
|
||||
test('failed_jobs daily-total threshold creates incident severity=medium', function () {
|
||||
$yesterday = Carbon::now()->subHours(12);
|
||||
for ($i = 0; $i < 51; $i++) {
|
||||
makeFailedJob(
|
||||
'App\\Jobs\\GenerateReportJob',
|
||||
'PDOException: SQLSTATE connection refused',
|
||||
$yesterday
|
||||
);
|
||||
}
|
||||
|
||||
$this->artisan('incidents:watch-failures', ['--threshold-daily' => 50])->assertSuccessful();
|
||||
|
||||
$incidents = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%daily-total%')
|
||||
->get();
|
||||
|
||||
expect($incidents)->toHaveCount(1);
|
||||
expect($incidents->first()->severity)->toBe('medium');
|
||||
|
||||
// Medium — no mail
|
||||
Mail::assertNotSent(IncidentDetectedMail::class);
|
||||
});
|
||||
|
||||
test('failed_jobs persistent exception creates incident severity=medium', function () {
|
||||
$old = Carbon::now()->subHours(4);
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
makeFailedJob(
|
||||
'App\\Jobs\\CsvReconcileJob',
|
||||
'Illuminate\\Database\\QueryException: duplicate key value',
|
||||
$old
|
||||
);
|
||||
}
|
||||
|
||||
$this->artisan('incidents:watch-failures', ['--persistent-hours' => 3])->assertSuccessful();
|
||||
|
||||
$incidents = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%persistent%')
|
||||
->get();
|
||||
|
||||
expect($incidents)->toHaveCount(1);
|
||||
expect($incidents->first()->severity)->toBe('medium');
|
||||
|
||||
// Medium — no mail
|
||||
Mail::assertNotSent(IncidentDetectedMail::class);
|
||||
});
|
||||
|
||||
test('dedup prevents duplicate incidents for same failed_jobs spike', function () {
|
||||
$now = Carbon::now();
|
||||
for ($i = 0; $i < 11; $i++) {
|
||||
makeFailedJob('App\\Jobs\\ImportLeadsJob', 'RuntimeException: quota exceeded', $now);
|
||||
}
|
||||
|
||||
// First run — creates incident
|
||||
$this->artisan('incidents:watch-failures', ['--threshold-spike' => 10])->assertSuccessful();
|
||||
expect(DB::table('incidents_log')->where('summary', 'like', '%spike%')->count())->toBe(1);
|
||||
|
||||
// Second run — dedup kicks in
|
||||
$this->artisan('incidents:watch-failures', ['--threshold-spike' => 10])->assertSuccessful();
|
||||
expect(DB::table('incidents_log')->where('summary', 'like', '%spike%')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('mail is sent only for high severity, not for medium', function () {
|
||||
$now = Carbon::now();
|
||||
|
||||
// High: webhook spike
|
||||
for ($i = 0; $i < 201; $i++) {
|
||||
makeFailedWebhookJobExp('App\\Exceptions\\WebhookException: ssl error', $now);
|
||||
}
|
||||
|
||||
// Medium: daily-total
|
||||
$yesterday = Carbon::now()->subHours(12);
|
||||
for ($i = 0; $i < 55; $i++) {
|
||||
makeFailedJob('App\\Jobs\\CleanupInactiveSupplierProjectsJob', 'RuntimeException: cleanup fail', $yesterday);
|
||||
}
|
||||
|
||||
$this->artisan('incidents:watch-failures', ['--threshold-daily' => 50])->assertSuccessful();
|
||||
|
||||
// Only 1 mail for the high webhook incident
|
||||
Mail::assertSent(IncidentDetectedMail::class, 1);
|
||||
});
|
||||
|
||||
test('warn-only when no saas_admin_users exist', function () {
|
||||
// Remove all admins
|
||||
DB::table('saas_admin_users')->delete();
|
||||
|
||||
$now = Carbon::now();
|
||||
for ($i = 0; $i < 11; $i++) {
|
||||
makeFailedJob('App\\Jobs\\SyncSupplierProjectsJob', 'RuntimeException: no admin', $now);
|
||||
}
|
||||
|
||||
$this->artisan('incidents:watch-failures', ['--threshold-spike' => 10])
|
||||
->assertSuccessful(); // SUCCESS not FAILURE
|
||||
|
||||
// No incidents created (no admin FK)
|
||||
expect(DB::table('incidents_log')->count())->toBe(0);
|
||||
|
||||
// No mail
|
||||
Mail::assertNotSent(IncidentDetectedMail::class);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ beforeEach(function () {
|
||||
$this->partitionsBefore = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->pluck('relname')->all();
|
||||
});
|
||||
|
||||
@@ -25,14 +25,15 @@ afterEach(function () {
|
||||
$partitionsAfter = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->pluck('relname')->all();
|
||||
|
||||
// DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от
|
||||
// webhook_dedup_keys → deals (parent partitioned table), и
|
||||
// последующий ON DELETE CASCADE тест валится.
|
||||
// Восстанавливаем имя parent-таблицы из имени партиции `<parent>_yYYYY_mMM`
|
||||
foreach (array_diff($partitionsAfter, $this->partitionsBefore) as $partition) {
|
||||
$parent = str_starts_with($partition, 'deals_') ? 'deals' : 'supplier_lead_costs';
|
||||
$parent = preg_replace('/_y?\d{4}_m?\d{2}$/', '', $partition);
|
||||
DB::statement("ALTER TABLE {$parent} DETACH PARTITION {$partition}");
|
||||
DB::statement("DROP TABLE IF EXISTS {$partition}");
|
||||
}
|
||||
@@ -45,8 +46,8 @@ test('создаёт партиции на N месяцев вперёд для
|
||||
|
||||
// Должны быть партиции до текущий+8 месяцев включительно.
|
||||
$futureMonth = now()->startOfMonth()->addMonths(8);
|
||||
$expectedDealName = 'deals_'.$futureMonth->format('Y_m');
|
||||
$expectedCostName = 'supplier_lead_costs_'.$futureMonth->format('Y_m');
|
||||
$expectedDealName = 'deals_'.'y'.$futureMonth->format('Y').'_m'.$futureMonth->format('m');
|
||||
$expectedCostName = 'supplier_lead_costs_'.'y'.$futureMonth->format('Y').'_m'.$futureMonth->format('m');
|
||||
|
||||
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$expectedDealName]);
|
||||
expect($row)->not->toBeNull();
|
||||
@@ -60,7 +61,7 @@ test('идемпотентность: повторный запуск не па
|
||||
$afterFirst = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->count();
|
||||
|
||||
// Повторный запуск — должен только skip'ать.
|
||||
@@ -70,21 +71,21 @@ test('идемпотентность: повторный запуск не па
|
||||
$afterSecond = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->count();
|
||||
|
||||
expect($afterSecond)->toBe($afterFirst);
|
||||
|
||||
// Output второго запуска должен говорить «skipped» по всем 12 партициям (6 мес × 2 табл).
|
||||
// Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции.
|
||||
$output = Artisan::output();
|
||||
expect($output)->toContain('0 created, 12 skipped');
|
||||
expect($output)->toContain('0 created, 54 skipped');
|
||||
});
|
||||
|
||||
test('--ahead=0 создаёт только текущий месяц', function () {
|
||||
Artisan::call('partitions:create-months', ['--ahead' => 0]);
|
||||
|
||||
$currentMonth = now()->startOfMonth();
|
||||
$name = 'deals_'.$currentMonth->format('Y_m');
|
||||
$name = 'deals_y'.$currentMonth->format('Y').'_m'.$currentMonth->format('m');
|
||||
|
||||
$row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$name]);
|
||||
expect($row)->not->toBeNull();
|
||||
|
||||
@@ -73,15 +73,18 @@ it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS
|
||||
$schema = file_get_contents($schemaPath);
|
||||
expect($schema)->not->toBeFalse();
|
||||
|
||||
// 65 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
|
||||
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
|
||||
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
|
||||
//
|
||||
// 67 base tables = все CREATE TABLE минус PARTITION OF.
|
||||
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
|
||||
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
|
||||
$baseTables = $createTables - $partitionOf;
|
||||
expect($baseTables)->toBe(65);
|
||||
expect($baseTables)->toBe(67);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(123); // v8.26: +2 supplier_projects_platform_key_subject_unique, idx_psl_*
|
||||
expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(40);
|
||||
expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\WebhookDedupKey;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Тесты ProcessWebhookJob — двустадийный dedup v8.6 (CTO-17).
|
||||
@@ -25,8 +26,12 @@ use Illuminate\Support\Str;
|
||||
* NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
|
||||
* trait оборачивает каждый тест в outer-транзакцию — Laravel-PG-driver
|
||||
* корректно обрабатывает nested через savepoints.
|
||||
*
|
||||
* SharesSupplierPdo: failed() now inserts via pgsql_supplier (BYPASSRLS) —
|
||||
* share PDO so DatabaseTransactions cross-connection visibility works on dev.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function makePayload(int $vid = 432176649, ?int $time = null): array
|
||||
{
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
|
||||
@@ -11,8 +11,10 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Console\Commands\SchedulerCheckHeartbeats;
|
||||
use App\Mail\SchedulerHeartbeatMissingMail;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
// Гарантируем PDO-sharing перед каждым тестом, затрагивающим pgsql_supplier.
|
||||
// SharesSupplierPdo::setUpSharesSupplierPdo() вызывается автоматически через
|
||||
// setUp{TraitName}, но явный beforeEach страхует от edge-cases.
|
||||
beforeEach(function (): void {
|
||||
DB::connection('pgsql_supplier')->setPdo(
|
||||
DB::connection('pgsql')->getPdo()
|
||||
);
|
||||
DB::connection('pgsql_supplier')->setReadPdo(
|
||||
DB::connection('pgsql')->getReadPdo()
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Получить строку heartbeat через default connection.
|
||||
* В test-env оба pgsql + pgsql_supplier указывают на liderra_testing.
|
||||
*/
|
||||
function getHeartbeat(string $name): ?object
|
||||
{
|
||||
return DB::table('scheduler_heartbeats')
|
||||
->where('command_name', $name)
|
||||
->first();
|
||||
}
|
||||
|
||||
function insertHeartbeat(array $data): void
|
||||
{
|
||||
$defaults = [
|
||||
'last_run_at' => null,
|
||||
'last_success_at' => null,
|
||||
'last_error' => null,
|
||||
'runtime_ms' => null,
|
||||
'consecutive_failures' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
DB::table('scheduler_heartbeats')->insert(array_merge($defaults, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Гарантирует наличие активного saas_admin_user для FK incidents_log.
|
||||
* Паттерн из IncidentsWatchFailuresTest::ensureSystemAdmin().
|
||||
*/
|
||||
function ensureHeartbeatAdmin(): int
|
||||
{
|
||||
$id = DB::table('saas_admin_users')->where('is_active', true)->whereNull('deleted_at')->value('id');
|
||||
|
||||
if ($id !== null) {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'hb-check-admin@liderra.ru',
|
||||
'full_name' => 'Heartbeat Check Admin',
|
||||
'password_hash' => '$2y$12$placeholder',
|
||||
'is_active' => true,
|
||||
'role' => 'support',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchedulerHeartbeatTracker::recordRun — успешный запуск
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('recordRun обновляет last_run_at и last_success_at при успехе', function (): void {
|
||||
$tracker = app(SchedulerHeartbeatTracker::class);
|
||||
$before = now()->subSecond();
|
||||
|
||||
$tracker->recordRun('test:success', fn () => null);
|
||||
|
||||
$row = getHeartbeat('test:success');
|
||||
|
||||
expect($row)->not->toBeNull('строка heartbeat не создана')
|
||||
->and(Carbon::parse($row->last_run_at))->toBeGreaterThan($before)
|
||||
->and(Carbon::parse($row->last_success_at))->toBeGreaterThan($before)
|
||||
->and($row->consecutive_failures)->toBe(0)
|
||||
->and($row->last_error)->toBeNull();
|
||||
});
|
||||
|
||||
it('recordRun сбрасывает consecutive_failures до 0 после успеха', function (): void {
|
||||
// Создаём строку с ненулевыми consecutive_failures
|
||||
insertHeartbeat([
|
||||
'command_name' => 'test:reset-failures',
|
||||
'consecutive_failures' => 5,
|
||||
'last_error' => 'prev error',
|
||||
]);
|
||||
|
||||
$tracker = app(SchedulerHeartbeatTracker::class);
|
||||
$tracker->recordRun('test:reset-failures', fn () => null);
|
||||
|
||||
$row = getHeartbeat('test:reset-failures');
|
||||
|
||||
expect($row->consecutive_failures)->toBe(0)
|
||||
->and($row->last_error)->toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchedulerHeartbeatTracker::recordRun — исключение
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('recordRun обновляет last_error и инкрементирует consecutive_failures при exception', function (): void {
|
||||
$tracker = app(SchedulerHeartbeatTracker::class);
|
||||
$before = now()->subSecond();
|
||||
|
||||
$thrown = false;
|
||||
|
||||
try {
|
||||
$tracker->recordRun('test:fail', function (): never {
|
||||
throw new RuntimeException('test error message');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
$thrown = true;
|
||||
}
|
||||
|
||||
expect($thrown)->toBeTrue('исключение должно пробрасываться');
|
||||
|
||||
$row = getHeartbeat('test:fail');
|
||||
|
||||
expect($row)->not->toBeNull('строка heartbeat не создана')
|
||||
->and(Carbon::parse($row->last_run_at))->toBeGreaterThan($before)
|
||||
->and($row->last_success_at)->toBeNull()
|
||||
->and($row->last_error)->toContain('test error message')
|
||||
->and($row->consecutive_failures)->toBe(1);
|
||||
});
|
||||
|
||||
it('recordRun инкрементирует consecutive_failures накопительно', function (): void {
|
||||
insertHeartbeat([
|
||||
'command_name' => 'test:multi-fail',
|
||||
'consecutive_failures' => 2,
|
||||
]);
|
||||
|
||||
$tracker = app(SchedulerHeartbeatTracker::class);
|
||||
|
||||
try {
|
||||
$tracker->recordRun('test:multi-fail', function (): never {
|
||||
throw new RuntimeException('again');
|
||||
});
|
||||
} catch (RuntimeException) {
|
||||
}
|
||||
|
||||
$row = getHeartbeat('test:multi-fail');
|
||||
expect($row->consecutive_failures)->toBe(3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchedulerCheckHeartbeats — детекция пропавшего пульса
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('SchedulerCheckHeartbeats флагует команду с last_run_at старше 2× интервала', function (): void {
|
||||
Mail::fake();
|
||||
|
||||
ensureHeartbeatAdmin();
|
||||
|
||||
// Команда incidents:watch-failures: интервал 10 минут → старше 20 мин = флаг
|
||||
insertHeartbeat([
|
||||
'command_name' => 'incidents:watch-failures',
|
||||
'last_run_at' => now()->subMinutes(25),
|
||||
'last_success_at' => now()->subMinutes(25),
|
||||
]);
|
||||
|
||||
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk()->run();
|
||||
|
||||
$incident = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%incidents:watch-failures%')
|
||||
->first();
|
||||
|
||||
expect($incident)->not->toBeNull('incident не создан для пропавшего пульса')
|
||||
->and($incident->severity)->toBe('high');
|
||||
});
|
||||
|
||||
it('SchedulerCheckHeartbeats флагует команду с consecutive_failures >= 3', function (): void {
|
||||
Mail::fake();
|
||||
|
||||
ensureHeartbeatAdmin();
|
||||
|
||||
insertHeartbeat([
|
||||
'command_name' => 'supplier:retry-failed',
|
||||
'last_run_at' => now()->subMinutes(5),
|
||||
'last_success_at' => now()->subMinutes(65),
|
||||
'consecutive_failures' => 3,
|
||||
]);
|
||||
|
||||
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
|
||||
|
||||
$incident = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%supplier:retry-failed%')
|
||||
->first();
|
||||
|
||||
expect($incident)->not->toBeNull('incident не создан при consecutive_failures=3');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dedup — повторный запуск не дублирует инцидент
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('SchedulerCheckHeartbeats не дублирует incident при повторном запуске', function (): void {
|
||||
Mail::fake();
|
||||
|
||||
ensureHeartbeatAdmin();
|
||||
|
||||
insertHeartbeat([
|
||||
'command_name' => 'audit:verify-chains',
|
||||
'last_run_at' => now()->subDays(3),
|
||||
'last_success_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
|
||||
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
|
||||
|
||||
$count = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%audit:verify-chains%')
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(1, 'инцидент задублирован — dedup не работает');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mailable отправляется
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('SchedulerCheckHeartbeats отправляет SchedulerHeartbeatMissingMail', function (): void {
|
||||
Mail::fake();
|
||||
|
||||
ensureHeartbeatAdmin();
|
||||
|
||||
insertHeartbeat([
|
||||
'command_name' => 'partitions:create-months',
|
||||
'last_run_at' => now()->subHours(50),
|
||||
'last_success_at' => now()->subHours(50),
|
||||
]);
|
||||
|
||||
$this->artisan(SchedulerCheckHeartbeats::class)->assertOk();
|
||||
|
||||
Mail::assertSent(SchedulerHeartbeatMissingMail::class, function ($mail) {
|
||||
return $mail->hasTo('kdv1@bk.ru');
|
||||
});
|
||||
});
|
||||
@@ -516,6 +516,77 @@ it('online pause: when the group has no active project left, supplier receives s
|
||||
expect(SupplierProject::where('unique_key', $common)->whereNotNull('inactive_since')->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online create: transient failure on one platform throws so the job retries (partial set not left silently)', function (): void {
|
||||
// Atomicity gap (owner-approved fix 2026-05-23): the 3 platforms are created by 3
|
||||
// sequential supplier calls. If one fails transiently, the other 2 are created and the
|
||||
// 3rd is silently skipped → group under-orders ~1/3 until the next sync. Fix: when a
|
||||
// platform is skipped for a TRANSIENT reason (not escalation/window-defer), throw so the
|
||||
// Laravel retry (backoff) re-runs and partial-set recovery fills the missing platform.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '70000009999',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 9,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$this->mock(\App\Services\Supplier\SupplierPortalClient::class, function ($mock): void {
|
||||
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
|
||||
if ($dto->platform === 'B3') {
|
||||
throw new \RuntimeException('transient: connection reset by peer');
|
||||
}
|
||||
|
||||
return [$dto->platform => ($dto->platform === 'B1' ? 6001 : 6002)];
|
||||
});
|
||||
});
|
||||
|
||||
// Transient miss on B3 → job must throw (so Laravel retries).
|
||||
expect(fn () => (new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class)))
|
||||
->toThrow(\RuntimeException::class);
|
||||
|
||||
// Progress is preserved: B1 + B2 are created so the retry only fills B3.
|
||||
expect(SupplierProject::where('unique_key', '70000009999')->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('online create: escalation/window-defer of one platform does NOT throw (legitimate skip, no retry)', function (): void {
|
||||
// Escalation (manual queue) and window-defer (portal after 18:00) are legitimate skips
|
||||
// with their own recovery (manual queue / nightly batch). Retrying would not help and
|
||||
// would only spam failed_jobs — so they must NOT trigger the retry throw.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '70000008888',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 9,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$this->mock(\App\Services\Supplier\SupplierPortalClient::class, function ($mock): void {
|
||||
$mock->shouldReceive('saveProjectMultiFlag')->andReturnUsing(function ($dto) {
|
||||
if ($dto->platform === 'B3') {
|
||||
throw new \App\Services\Supplier\Channel\Exceptions\WindowDeferredException('portal window closed');
|
||||
}
|
||||
|
||||
return [$dto->platform => ($dto->platform === 'B1' ? 7001 : 7002)];
|
||||
});
|
||||
});
|
||||
|
||||
// Legitimate skip on B3 → job completes without throwing.
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
// B1 + B2 created; B3 left for manual queue / nightly batch (no exception).
|
||||
expect(SupplierProject::where('unique_key', '70000008888')->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('runs every projects query on the pgsql_supplier (BYPASSRLS) connection', function (): void {
|
||||
// Regression: job ran on the default RLS-enforced connection. On a real queue worker
|
||||
// (role crm_app_user, no SetTenantContext middleware → no app.current_tenant_id GUC)
|
||||
|
||||
@@ -69,6 +69,14 @@ describe('NewProjectDialog — required region gate + «Вся РФ» (Plan 4 Ta
|
||||
expect(payload.regions).toEqual([]);
|
||||
});
|
||||
|
||||
it('region autocomplete has closable-chips so a single region can be removed', async () => {
|
||||
const w = factory();
|
||||
await flushPromises();
|
||||
|
||||
const ac = w.findComponent('[data-testid="regions-autocomplete"]');
|
||||
expect(ac.props('closableChips')).toBe(true);
|
||||
});
|
||||
|
||||
it('picking subjects after «Вся РФ» clears the confirmation (mutual exclusion)', async () => {
|
||||
const w = factory();
|
||||
await flushPromises();
|
||||
|
||||
@@ -211,4 +211,11 @@ describe('ProjectDetailsDrawer', () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith('/api/projects/42', expect.objectContaining({ regions: [] }));
|
||||
});
|
||||
|
||||
it('region autocomplete has closable-chips so a single region can be removed', () => {
|
||||
const withRegions: Project = { ...sampleProject, regions: [1, 2] };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
expect(autocomplete.props('closableChips')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,4 +55,12 @@ describe('RegionsBulkDialog', () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('both region selectors have closable-chips so a single subject can be removed', () => {
|
||||
const wrapper = mountDialog();
|
||||
const addAc = wrapper.findComponent('[data-testid="region-add-select"]');
|
||||
const removeAc = wrapper.findComponent('[data-testid="region-remove-select"]');
|
||||
expect(addAc.props('closableChips')).toBe(true);
|
||||
expect(removeAc.props('closableChips')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1629,3 +1629,84 @@ CDP
|
||||
субдомены
|
||||
артизан
|
||||
Артизан
|
||||
деплоем
|
||||
эксцепшне
|
||||
коммитах
|
||||
пофиксить
|
||||
даунгрейднут
|
||||
ребейзов
|
||||
GUC
|
||||
Postiz
|
||||
SERP
|
||||
dataforseo
|
||||
postiz
|
||||
qatest
|
||||
rollup
|
||||
unparseable
|
||||
antoniolg
|
||||
chigwell
|
||||
стэш
|
||||
ветом
|
||||
даунгрейд
|
||||
диспатчат
|
||||
мерж
|
||||
неймы
|
||||
ретестов
|
||||
роутах
|
||||
синканный
|
||||
Vite
|
||||
сериализуется
|
||||
флагует
|
||||
клиентно
|
||||
|
||||
# Billing v2 Spec A (23.05.2026)
|
||||
vtb
|
||||
брейнсторм
|
||||
подписочной
|
||||
брейнсторму
|
||||
ревьюю
|
||||
|
||||
# Hole #6 (23.05.2026)
|
||||
FQCN
|
||||
|
||||
брейнсторма
|
||||
journalctl
|
||||
свежесозданный
|
||||
недозаказывала
|
||||
досоздаёт
|
||||
недозаказ
|
||||
пушнута
|
||||
jre
|
||||
Eljakani
|
||||
eljakani
|
||||
coreyhaines
|
||||
Vadosdavos
|
||||
Yahia
|
||||
Svecha
|
||||
PVL
|
||||
gitroomhq
|
||||
накопл
|
||||
инвокаций
|
||||
мэппингом
|
||||
экспект
|
||||
деплоен
|
||||
пинует
|
||||
pgrx
|
||||
роутинга
|
||||
сурфейсятся
|
||||
Temurin
|
||||
jdk
|
||||
|
||||
# Hole #6 + #3+#5 + #4 closure (23.05.2026 вечер)
|
||||
алертил
|
||||
бэкапом
|
||||
залогиненную
|
||||
FNS
|
||||
булиты
|
||||
дебаг
|
||||
валидируется
|
||||
рендериться
|
||||
|
||||
# Hole #2 partitioning (23.05.2026)
|
||||
партиционировать
|
||||
дёшева
|
||||
|
||||
+12
-1
@@ -38,5 +38,16 @@
|
||||
],
|
||||
"allowCompoundWords": true,
|
||||
"minWordLength": 3,
|
||||
"useGitignore": true
|
||||
"useGitignore": true,
|
||||
"words": [
|
||||
"сериализуется",
|
||||
"флагует",
|
||||
"клиентно",
|
||||
"даунгрейднут",
|
||||
"ребейзов",
|
||||
"деплоем",
|
||||
"эксцепшне",
|
||||
"коммитах",
|
||||
"пофиксить"
|
||||
]
|
||||
}
|
||||
|
||||
+73
-2
@@ -1,14 +1,85 @@
|
||||
# CHANGELOG schema.sql — Лидерра
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать шесть записей в обратном хронологическом порядке (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.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.29, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.31, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
**История записей:**
|
||||
|
||||
## v8.31 — 2026-05-23 — партиционирование 7 audit-таблиц (hole #2)
|
||||
|
||||
Закрывает дыру #2 аудита журналирования: все 7 audit-таблиц переведены на
|
||||
RANGE-партиционирование помесячно. Управление партициями — `MonthlyPartitionManager`
|
||||
(extended до 9 таблиц) + cron `partitions:create-months` + cron `partitions:drop-expired` (новый).
|
||||
|
||||
**Таблицы, переведённые на партиционирование:**
|
||||
|
||||
| Таблица | Partition key | PK до | PK после |
|
||||
|---|---|---|---|
|
||||
| `auth_log` | `created_at` | `(id)` | `(id, created_at)` |
|
||||
| `activity_log` | `created_at` | `(id)` | `(id, created_at)` |
|
||||
| `tenant_operations_log` | `created_at` | `(id)` | `(id, created_at)` |
|
||||
| `webhook_log` | `received_at` | `(id)` | `(id, received_at)` |
|
||||
| `balance_transactions` | `created_at` | `(id)` | `(id, created_at)` |
|
||||
| `pd_processing_log` | `created_at` | `(id)` | `(id, created_at)` |
|
||||
| `saas_admin_audit_log` | `created_at` | `(id)` | `(id, created_at)` |
|
||||
|
||||
**FK удалены (W1):**
|
||||
|
||||
- `failed_webhook_jobs.webhook_log_id` — FK снят, колонка сохранена как `BIGINT` (без ссылочной целостности; composite PK партиционированной таблицы несовместим с одиночным FK-столбцом)
|
||||
- `rejected_deals_log.webhook_log_id` — аналогично
|
||||
|
||||
**Partition naming format:** `<table>_y<YYYY>_m<MM>` (пример: `auth_log_y2026_m05`).
|
||||
Применён и к ранее существующим таблицам `deals` / `supplier_lead_costs` — partition children
|
||||
в schema.sql переименованы.
|
||||
|
||||
**tenant_operations_log:** RLS и триггеры перенесены из inline-определения таблицы в
|
||||
централизованные секции (единообразно с остальными таблицами). Счётчик триггеров: 5 → 6 пар.
|
||||
|
||||
**Retention defaults (в system_settings через migration):**
|
||||
|
||||
- `auth_log_retention_months = 24`
|
||||
- `activity_log_retention_months = 36`
|
||||
- `tenant_operations_log_retention_months = 24`
|
||||
- `webhook_log_retention_months = 3`
|
||||
- `balance_transactions_retention_months = 84`
|
||||
- `pd_processing_log_retention_months = 36`
|
||||
- `saas_admin_audit_log_retention_months = 84`
|
||||
|
||||
Миграция: `2026_05_23_000002_partition_audit_tables.php`.
|
||||
|
||||
**Метрики после:** 74 таблицы (65 regular + 9 partitioned parents) / 125 индексов / 41 RLS / 6 пар audit-триггеров / 5 user-функций.
|
||||
|
||||
## v8.30 — 2026-05-23 — scheduler_heartbeats (hole #6 cron heartbeat)
|
||||
|
||||
+1 таблица `scheduler_heartbeats` — SaaS-уровневый пульс всех cron-задач (дыра #6 аудита
|
||||
журналирования). Без RLS (не тенант-уровневая). PK = `command_name VARCHAR(200)`.
|
||||
|
||||
**Колонки:**
|
||||
|
||||
- `command_name VARCHAR(200) NOT NULL PRIMARY KEY` — имя команды / FQCN джоба
|
||||
- `last_run_at TIMESTAMPTZ` — последний запуск (любой исход)
|
||||
- `last_success_at TIMESTAMPTZ` — последний успешный запуск
|
||||
- `last_error TEXT` — последнее сообщение ошибки (до 2000 символов)
|
||||
- `runtime_ms INT` — время выполнения последнего запуска в мс
|
||||
- `consecutive_failures INT NOT NULL DEFAULT 0` — счётчик последовательных ошибок
|
||||
- `created_at / updated_at TIMESTAMPTZ DEFAULT NOW()`
|
||||
|
||||
**Индексов нет** — 11 строк (по числу cron-задач), полное сканирование дешевле индекса.
|
||||
|
||||
**Запись:** UPSERT через `SchedulerHeartbeatTracker::recordRunResult()` / `recordRun()` в
|
||||
`routes/console.php` (before/after/onFailure хуки каждой cron-задачи).
|
||||
|
||||
**Мониторинг:** `SchedulerCheckHeartbeats` (hourly) — создаёт `incidents_log` + email при
|
||||
пропавшем пульсе (>2× ожидаемого интервала) или `consecutive_failures >= 3`.
|
||||
|
||||
Миграция: `2026_05_23_000001_create_scheduler_heartbeats_table.php`.
|
||||
Метрики после: 67 таблиц (65 regular + 2 partitioned) / 126 индексов / 41 RLS / 15 триггеров.
|
||||
|
||||
## v8.29 — 2026-05-22 — webhook_log: supplier audit columns
|
||||
|
||||
`webhook_log` таблица расширена для аудита входящих запросов поставщика:
|
||||
|
||||
- `tenant_id` сделан nullable (platform-level события не имеют tenant context)
|
||||
- +4 колонки: `source VARCHAR(50)`, `status VARCHAR(50)`, `lead_id BIGINT`, `ip_address INET`, `created_at TIMESTAMPTZ`
|
||||
- +1 индекс `idx_webhook_log_status(status, created_at DESC)`
|
||||
|
||||
+108
-54
@@ -1,8 +1,11 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
|
||||
-- Версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings)
|
||||
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
|
||||
-- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns)
|
||||
-- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
|
||||
-- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
|
||||
-- Метрики: 66 базовые таблицы (64 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 125 индексов / 41 RLS-политика / 5 функций / 15 триггеров
|
||||
-- Метрики: 74 базовые таблицы (65 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 41 RLS-политика / 5 функций / 15 триггеров
|
||||
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
|
||||
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
|
||||
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
@@ -1448,8 +1451,12 @@ CREATE INDEX idx_outbound_deliveries_created ON outbound_webhook_deliv
|
||||
-- РАСШИРЕНИЕ v8.1: добавлены actor_type и saas_admin_user_id для объединения
|
||||
-- логов входов клиентских пользователей и админов SaaS в одной таблице.
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- v8.31: партиционирована помесячно по created_at (hole #2).
|
||||
-- PK изменён с (id) на (id, created_at) для совместимости с RANGE-партиционированием PG 16.
|
||||
-- Стартовые партиции создаются миграцией 2026_05_23_000002_partition_audit_tables
|
||||
-- и далее cron'ом partitions:create-months.
|
||||
CREATE TABLE auth_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
id BIGSERIAL,
|
||||
actor_type VARCHAR(20) NOT NULL DEFAULT 'tenant_user'
|
||||
CHECK (actor_type IN ('tenant_user','saas_admin')),
|
||||
tenant_id BIGINT REFERENCES tenants(id), -- NULL для админов SaaS
|
||||
@@ -1467,15 +1474,16 @@ CREATE TABLE auth_log (
|
||||
-- среднюю строку или вставит фейковую — пересчёт цепочки покажет
|
||||
-- разрыв. UPDATE/DELETE заблокированы триггером BEFORE.
|
||||
log_hash BYTEA, -- NULL → fill via trigger BEFORE INSERT
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
-- Целостность: actor должен быть ровно одного типа
|
||||
CONSTRAINT chk_auth_log_actor CHECK (
|
||||
(actor_type = 'tenant_user' AND user_id IS NOT NULL AND saas_admin_user_id IS NULL)
|
||||
OR (actor_type = 'saas_admin' AND saas_admin_user_id IS NOT NULL AND user_id IS NULL)
|
||||
OR (actor_type = 'tenant_user' AND user_id IS NULL AND saas_admin_user_id IS NULL AND email IS NOT NULL)
|
||||
-- Третий вариант: попытка входа с email, но user_id неизвестен (login_failed для неcуществующего email)
|
||||
)
|
||||
);
|
||||
),
|
||||
PRIMARY KEY (id, created_at) -- v8.31: composite PK (partition key required)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE INDEX idx_auth_log_tenant_user ON auth_log(tenant_id, user_id, created_at DESC);
|
||||
CREATE INDEX idx_auth_log_admin ON auth_log(saas_admin_user_id, created_at DESC) WHERE saas_admin_user_id IS NOT NULL;
|
||||
@@ -1685,12 +1693,13 @@ CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд).
|
||||
-- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026).
|
||||
CREATE TABLE deals_2026_05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE deals_2026_06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE deals_2026_07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE deals_2026_08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
|
||||
CREATE TABLE deals_2026_09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
|
||||
CREATE TABLE deals_2026_10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
||||
-- v8.31: переименованы в формат <table>_y<YYYY>_m<MM> (совпадает с MonthlyPartitionManager::partitionName()).
|
||||
CREATE TABLE deals_y2026_m05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE deals_y2026_m06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE deals_y2026_m07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE deals_y2026_m08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
|
||||
CREATE TABLE deals_y2026_m09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
|
||||
CREATE TABLE deals_y2026_m10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -1764,8 +1773,9 @@ CREATE INDEX idx_deal_tag_pivot_tag ON deal_tag_pivot(tag_id);
|
||||
-- activity_log — журнал действий по сделкам (раздел 14.4)
|
||||
-- РЕТЕНШН: 3 года активно, далее в S3 Glacier
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
|
||||
CREATE TABLE activity_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
id BIGSERIAL,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id BIGINT REFERENCES users(id), -- NULL для системных событий
|
||||
deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована)
|
||||
@@ -1776,8 +1786,9 @@ CREATE TABLE activity_log (
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
PRIMARY KEY (id, created_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE INDEX idx_activity_tenant_deal_created ON activity_log(tenant_id, deal_id, created_at DESC);
|
||||
CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id, created_at DESC) WHERE user_id IS NOT NULL;
|
||||
@@ -1786,8 +1797,10 @@ CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id
|
||||
-- tenant_operations_log — журнал тенант-уровневых операций вне сделок
|
||||
-- (проекты, API-ключи, исходящий webhook URL, и т.п.). Защищён hash-chain.
|
||||
-- =============================================================================
|
||||
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
|
||||
-- RLS и триггеры перенесены в секцию RLS/Triggers (единообразно с другими партиционированными).
|
||||
CREATE TABLE tenant_operations_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
id BIGSERIAL,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id BIGINT REFERENCES users(id), -- NULL для системных
|
||||
entity_type VARCHAR(50) NOT NULL, -- 'project', 'api_key', 'webhook_settings'
|
||||
@@ -1798,8 +1811,9 @@ CREATE TABLE tenant_operations_log (
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
log_hash BYTEA, -- hash chain
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
PRIMARY KEY (id, created_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE INDEX idx_tenant_ops_tenant_created
|
||||
ON tenant_operations_log(tenant_id, created_at DESC);
|
||||
@@ -1807,17 +1821,6 @@ CREATE INDEX idx_tenant_ops_entity
|
||||
ON tenant_operations_log(tenant_id, entity_type, entity_id, created_at DESC)
|
||||
WHERE entity_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON tenant_operations_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_tenant_ops
|
||||
BEFORE INSERT ON tenant_operations_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
CREATE TRIGGER trg_audit_block_mut_tenant_ops
|
||||
BEFORE UPDATE OR DELETE ON tenant_operations_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- reminders — напоминания по сделкам (раздел 17.5)
|
||||
-- v8.3: расширено по итогам партии 12.2 аудита.
|
||||
@@ -1922,11 +1925,14 @@ COMMENT ON TABLE in_app_notifications IS
|
||||
-- webhook_log — лог принятых webhook (раздел 5.7)
|
||||
-- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at).
|
||||
-- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK
|
||||
-- партиционированной таблицы с единичным FK-столбцом).
|
||||
CREATE TABLE webhook_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
id BIGSERIAL,
|
||||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook)
|
||||
raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации
|
||||
received_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
processed_at TIMESTAMPTZ,
|
||||
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
|
||||
error TEXT,
|
||||
@@ -1935,8 +1941,9 @@ CREATE TABLE webhook_log (
|
||||
status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited'
|
||||
lead_id BIGINT, -- supplier_leads.id при статусе 'received'
|
||||
ip_address INET, -- клиентский IP
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC);
|
||||
CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC);
|
||||
@@ -1949,7 +1956,7 @@ CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC);
|
||||
CREATE TABLE failed_webhook_jobs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
webhook_log_id BIGINT REFERENCES webhook_log(id),
|
||||
webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
|
||||
raw_payload JSONB NOT NULL,
|
||||
exception TEXT NOT NULL,
|
||||
retry_count INT DEFAULT 3,
|
||||
@@ -1971,7 +1978,7 @@ CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id);
|
||||
CREATE TABLE rejected_deals_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
webhook_log_id BIGINT REFERENCES webhook_log(id),
|
||||
webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
|
||||
reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ...
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
@@ -2318,8 +2325,9 @@ CREATE INDEX idx_refund_requests_chargeback ON refund_requests(tenant_id, reque
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- balance_transactions — внутренний лид-биллинг (раздел 7.3, 21)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
|
||||
CREATE TABLE balance_transactions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
id BIGSERIAL,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL
|
||||
CHECK (type IN ('trial_bonus','topup','lead_charge','refund',
|
||||
@@ -2338,8 +2346,9 @@ CREATE TABLE balance_transactions (
|
||||
-- Для manual_adjustment — кто из админов SaaS сделал
|
||||
admin_user_id BIGINT REFERENCES saas_admin_users(id),
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
PRIMARY KEY (id, created_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE INDEX idx_balance_tenant_created ON balance_transactions(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_balance_tenant_type ON balance_transactions(tenant_id, type);
|
||||
@@ -2409,12 +2418,13 @@ CREATE INDEX ON supplier_lead_costs (supplier_invoice_id) WHERE supplier_invoice
|
||||
CREATE INDEX ON supplier_lead_costs (supplier_id, received_at DESC); -- v8.2: аналитика "лиды по поставщикам"
|
||||
|
||||
-- Партиции синхронно с deals (создаются cron на 2 месяца вперёд)
|
||||
CREATE TABLE supplier_lead_costs_2026_05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE supplier_lead_costs_2026_06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE supplier_lead_costs_2026_07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE supplier_lead_costs_2026_08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
|
||||
CREATE TABLE supplier_lead_costs_2026_09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
|
||||
CREATE TABLE supplier_lead_costs_2026_10 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
||||
-- v8.31: переименованы в формат <table>_y<YYYY>_m<MM>.
|
||||
CREATE TABLE supplier_lead_costs_y2026_m05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE supplier_lead_costs_y2026_m06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE supplier_lead_costs_y2026_m07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE supplier_lead_costs_y2026_m08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
|
||||
CREATE TABLE supplier_lead_costs_y2026_m09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
|
||||
CREATE TABLE supplier_lead_costs_y2026_m10 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -2488,8 +2498,9 @@ CREATE INDEX idx_consents_tenant ON tenant_consents(tenant_id, consent_type);
|
||||
-- pd_processing_log — журнал обработки ПДн (раздел 22.9.3)
|
||||
-- РАСШИРЕНИЕ v8.1: разделение actor_user_id на два поля с FK
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
|
||||
CREATE TABLE pd_processing_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
id BIGSERIAL,
|
||||
tenant_id BIGINT REFERENCES tenants(id),
|
||||
subject_type VARCHAR(50), -- 'user', 'lead'
|
||||
subject_id BIGINT,
|
||||
@@ -2500,13 +2511,14 @@ CREATE TABLE pd_processing_log (
|
||||
actor_admin_user_id BIGINT REFERENCES saas_admin_users(id),
|
||||
ip_address INET,
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
CONSTRAINT chk_pd_actor CHECK (
|
||||
(actor_tenant_user_id IS NOT NULL AND actor_admin_user_id IS NULL)
|
||||
OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NOT NULL)
|
||||
OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NULL) -- системное действие
|
||||
)
|
||||
);
|
||||
),
|
||||
PRIMARY KEY (id, created_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE INDEX idx_pd_log_tenant ON pd_processing_log(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_pd_log_admin_actor ON pd_processing_log(actor_admin_user_id, created_at DESC) WHERE actor_admin_user_id IS NOT NULL;
|
||||
@@ -2636,12 +2648,35 @@ COMMENT ON COLUMN incidents_log.rkn_notified_at IS
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 10. АДМИНКА SAAS — ЖУРНАЛ ДЕЙСТВИЙ (НОВАЯ)
|
||||
-- 10. ПУЛЬС ПЛАНИРОВЩИКА (SCHEDULER HEARTBEAT) — SaaS-level (hole #6)
|
||||
-- Без RLS — системная таблица SaaS уровня.
|
||||
-- Одна строка на каждую cron-задачу (PK = command_name).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE scheduler_heartbeats (
|
||||
command_name VARCHAR(200) NOT NULL PRIMARY KEY,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
runtime_ms INT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE scheduler_heartbeats IS
|
||||
'Пульс планировщика: одна строка на cron-задачу, обновляется при каждом запуске. '
|
||||
'SaaS-level, без RLS. Используется SchedulerCheckHeartbeats для детекции '
|
||||
'пропавших или постоянно падающих задач (hole #6).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 11. АДМИНКА SAAS — ЖУРНАЛ ДЕЙСТВИЙ (НОВАЯ)
|
||||
-- saas_admin_users уже создана выше (нужна была для FK от других таблиц)
|
||||
-- =============================================================================
|
||||
|
||||
-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at).
|
||||
-- Без RLS: доступна только crm_admin_user (BYPASSRLS).
|
||||
CREATE TABLE saas_admin_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
id BIGSERIAL,
|
||||
admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id),
|
||||
action VARCHAR(100) NOT NULL, -- 'tenant.suspend', 'refund.approve', 'system_settings.update', ...
|
||||
target_type VARCHAR(50), -- 'tenant', 'saas_transaction', 'system_setting', ...
|
||||
@@ -2657,8 +2692,9 @@ CREATE TABLE saas_admin_audit_log (
|
||||
approved_by BIGINT REFERENCES saas_admin_users(id),
|
||||
approved_at TIMESTAMPTZ,
|
||||
log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log)
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
PRIMARY KEY (id, created_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE INDEX idx_admin_audit_admin ON saas_admin_audit_log(admin_user_id, created_at DESC);
|
||||
CREATE INDEX idx_admin_audit_tenant ON saas_admin_audit_log(target_tenant_id, created_at DESC) WHERE target_tenant_id IS NOT NULL;
|
||||
@@ -2764,7 +2800,15 @@ INSERT INTO system_settings (key, value, type, description) VALUES
|
||||
('projects_purge_deleted_cron', '0 4 * * *', 'string', 'Расписание cron projects:purge-deleted (по умолчанию 04:00 МСК ежедневно)'),
|
||||
-- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth.
|
||||
('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
|
||||
('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.');
|
||||
('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'),
|
||||
-- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
|
||||
('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'),
|
||||
('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'),
|
||||
('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'),
|
||||
('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'),
|
||||
('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'),
|
||||
('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'),
|
||||
('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)');
|
||||
|
||||
|
||||
-- 4 стартовых тарифа-заглушки (Биз-1: вариант Б).
|
||||
@@ -2832,6 +2876,7 @@ ALTER TABLE deal_tags ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE import_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline)
|
||||
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY;
|
||||
@@ -2873,6 +2918,7 @@ CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = cur
|
||||
CREATE POLICY tenant_isolation ON import_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline
|
||||
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
@@ -3070,7 +3116,8 @@ COMMENT ON FUNCTION audit_block_mutation() IS
|
||||
'v8.5 (OPEN-И-15): запрещает UPDATE/DELETE на audit-таблицах. '
|
||||
'Совместно с REVOKE на роли — два слоя защиты от tampering.';
|
||||
|
||||
-- 5 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE)
|
||||
-- 6 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE)
|
||||
-- v8.31: tenant_operations_log перенесён из inline-определения таблицы; итого 6 пар.
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_auth_log
|
||||
BEFORE INSERT ON auth_log
|
||||
@@ -3086,6 +3133,13 @@ CREATE TRIGGER trg_audit_block_mut_activity_log
|
||||
BEFORE UPDATE OR DELETE ON activity_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_tenant_ops
|
||||
BEFORE INSERT ON tenant_operations_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
CREATE TRIGGER trg_audit_block_mut_tenant_ops
|
||||
BEFORE UPDATE OR DELETE ON tenant_operations_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_block_mutation();
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_pd_log
|
||||
BEFORE INSERT ON pd_processing_log
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_chain_hash();
|
||||
|
||||
+94
-1
@@ -361,6 +361,99 @@ Existing prose follows the table.
|
||||
|
||||
**Триггер:** первый коммит в `resources/js/` или отдельную папку Vue-приложения.
|
||||
|
||||
### §4.0 Краткая сводка узлов (auto-generated)
|
||||
|
||||
<!-- auto:tooling-registry-summary:begin -->
|
||||
<!-- This block is auto-generated from docs/registry/nodes.yaml. Do not edit by hand. -->
|
||||
|
||||
| ID | Узел | Категория | Статус |
|
||||
|---|---|---|---|
|
||||
| #2 | Playwright MCP | phase-0 | active |
|
||||
| #3 | GitHub MCP | phase-0 | active |
|
||||
| #4 | markdownlint-cli2 | phase-0 | active |
|
||||
| #5 | cspell | phase-0 | active |
|
||||
| #6 | lychee | phase-0 | active |
|
||||
| #7 | Stylelint | phase-0 | active |
|
||||
| #8 | gitleaks | phase-0 | active |
|
||||
| #9 | Pa11y | phase-0 | active |
|
||||
| #10 | Laravel Boost | phase-1 | active |
|
||||
| #11 | Laravel Pint | phase-1 | active |
|
||||
| #12 | Larastan | phase-1 | active |
|
||||
| #13 | Roave/SecurityAdvisories | phase-1 | active |
|
||||
| #14 | Laravel IDE Helper | phase-1 | active |
|
||||
| #15 | squawk | phase-1 | active |
|
||||
| #16 | pgFormatter | phase-1 | active |
|
||||
| #17 | pg_partman | phase-1 | dormant |
|
||||
| #19 | Superpowers v5.1.0 | phase-2 | active |
|
||||
| #18 | Pest 4 | phase-1 | active |
|
||||
| #1 | PostgreSQL MCP | phase-0 | historic |
|
||||
| #20 | Volar | phase-2 | active |
|
||||
| #21 | vue-tsc | phase-2 | active |
|
||||
| #22 | ESLint + Prettier + plugin-vue + config-prettier | phase-2 | active |
|
||||
| #23 | Vitest | phase-2 | active |
|
||||
| #24 | Histoire | phase-2 | active |
|
||||
| #25 | Semgrep + Semgrep MCP | phase-3 | active |
|
||||
| #26 | Trivy | phase-3 | active |
|
||||
| #27 | GitHub Dependabot | phase-3 | active |
|
||||
| #28 | pg_audit | phase-3 | active |
|
||||
| #29 | pg_anonymizer | phase-3 | active |
|
||||
| #30 | Frontend Design plugin | phase-2 | active |
|
||||
| #31 | UI UX Pro Max | off-phase | active |
|
||||
| #32 | 21st.dev Magic MCP | off-phase | active |
|
||||
| #33 | claude-md-management | off-phase | active |
|
||||
| #34 | Sentry MCP | off-phase | active |
|
||||
| #35 | Redis MCP | off-phase | active |
|
||||
| #36 | adr-kit | off-phase | active |
|
||||
| #37 | mermaid-skill | off-phase | active |
|
||||
| #38 | architecture-patterns | off-phase | active |
|
||||
| #39 | Trail of Bits Skills | off-phase | active |
|
||||
| #40 | Security Guidance | off-phase | active |
|
||||
| #41 | CCPM | off-phase | active |
|
||||
| #42 | product-management | off-phase | active |
|
||||
| #43 | deptrac | off-phase | active |
|
||||
| #44 | Figma MCP | off-phase | deferred |
|
||||
| #45 | Universal Icons MCP | off-phase | active |
|
||||
| #46 | Design plugin | off-phase | active |
|
||||
| #47 | openapi-mcp-server | off-phase | active |
|
||||
| #48 | promptfoo | off-phase | active |
|
||||
| #49 | Data Scientist skill | off-phase | active |
|
||||
| #50 | Jupyter MCP | off-phase | deferred |
|
||||
| #51 | operations | off-phase | active |
|
||||
| #52 | process-modeling | off-phase | active |
|
||||
| #53 | process-analysis | off-phase | active |
|
||||
| #54 | n8n-mcp | off-phase | deferred |
|
||||
| #55 | discovery-interview | off-phase | active |
|
||||
| #56 | skill-creator | off-phase | active |
|
||||
| #57 | plugin-dev | off-phase | active |
|
||||
| #58 | hookify | off-phase | active |
|
||||
| #59 | claude-code-setup | off-phase | active |
|
||||
| #60 | context7 | off-phase | active |
|
||||
| #61 | finance plugin | off-phase | active |
|
||||
| #62 | billing-audit | off-phase | active |
|
||||
| #63 | ru-tax-accounting | off-phase | active |
|
||||
| #64 | Rector | off-phase | active |
|
||||
| #65 | PHP Insights | off-phase | active |
|
||||
| #66 | laravel-backend-patterns | off-phase | active |
|
||||
| #67 | NightOwl | off-phase | deferred |
|
||||
| #68 | OWASP ZAP | off-phase | active |
|
||||
| #69 | Nuclei | off-phase | active |
|
||||
| #70 | Ward | off-phase | active |
|
||||
| #71 | pdn-152fz-audit | off-phase | active |
|
||||
| #72 | threat-model | off-phase | active |
|
||||
| #73 | security-go-live | off-phase | active |
|
||||
| #74 | marketing | off-phase | active |
|
||||
| #75 | marketingskills | off-phase | active |
|
||||
| #76 | brand-voice | off-phase | active |
|
||||
| #77 | marketing-ru | off-phase | active |
|
||||
| #78 | Яндекс.Метрика MCP | off-phase | active |
|
||||
| #79 | Яндекс.Директ+Wordstat MCP | off-phase | active |
|
||||
| #80 | Telegram MCP | off-phase | active |
|
||||
| #81 | Postiz | off-phase | active |
|
||||
| #82 | DataForSEO MCP | off-phase | deferred |
|
||||
| #83 | Unisender Go MCP | off-phase | deferred |
|
||||
|
||||
<!-- auto:tooling-registry-summary:end -->
|
||||
|
||||
### 4.1. Поведенческий слой — Superpowers (полный, hard rule)
|
||||
|
||||
**Атрибуты:**
|
||||
@@ -555,7 +648,7 @@ Existing prose follows the table.
|
||||
|
||||
| id | name | kind | phase | subcategory | triggers | boundaries | dormant | last-touched |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| #34 | Sentry MCP | mcp | off-phase | debug-runtime | «отладка production runtime errors» | READ-ONLY, pending Б-1 | false | 2026-05-19 |
|
||||
| #34 | Sentry MCP | mcp | off-phase | debug-runtime | «отладка production runtime errors» | DEFERRED — Sentry instance не задеплоен (pending Б-1); READ-ONLY когда активен | false | 2026-05-23 |
|
||||
|
||||
> **Введено 13.05.2026 day +1 (v1.17 Прил. Н):** формализован как «инструмент-резерв вне фаз, debug-категория». Установлен на feat/claude-automation `6f7e7d7` в `.mcp.json`, merged в main через PR #3 (`cc5f63b`); формализован retrospectively в v1.17. Категория **debug-runtime**, отличная от UI-пула (UPM/21st) и инфраструктурного (claude-md-management) — поэтому отдельная нумерация. Pending Sentry instance deployment в Yandex Cloud (зависит от Б-1 ООО registration P0).
|
||||
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
# RLS Gap Audit — Cron Commands & Queued Jobs
|
||||
|
||||
**Date:** 2026-05-23
|
||||
**Scope:** Static analysis of all cron-scheduled commands and queued jobs
|
||||
**Project:** Лидерра CRM (Laravel 13 / PostgreSQL 16)
|
||||
**Auditor:** Claude Code (Phase A — discovery only, read-only, no code changes)
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The portal uses PostgreSQL Row-Level Security (RLS). The policies use `current_setting('app.current_tenant_id')` to filter rows per tenant. On **DEV** the DB user is `postgres` (superuser, BYPASSRLS), so missing-GUC bugs are hidden. On **PRODUCTION** the role is `crm_app_user` / `liderra` (no BYPASSRLS), so any code that touches an RLS-protected table from a context where `app.current_tenant_id` is NOT set (cron commands, queued jobs outside an HTTP request) **fails** with:
|
||||
|
||||
```
|
||||
ERROR: unrecognized configuration parameter "app.current_tenant_id"
|
||||
```
|
||||
|
||||
or silently returns empty/wrong rows (if the policy uses `missing_ok = true`, which this codebase does NOT — it uses bare `current_setting()`).
|
||||
|
||||
The correct pattern for SaaS-admin/cron scope is to use the BYPASSRLS connection:
|
||||
|
||||
```php
|
||||
DB::connection('pgsql_supplier')->table('...') // role crm_supplier_worker, BYPASSRLS
|
||||
```
|
||||
|
||||
Precedent: `IncidentsWatchFailures.php` was hotfixed 22.05.2026 this exact way.
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — RLS-Protected Table Inventory
|
||||
|
||||
Tables with RLS policies referencing `current_setting('app.current_tenant_id')` (from `db/schema.sql`):
|
||||
|
||||
| Table | Notes |
|
||||
|---|---|
|
||||
| `users` | |
|
||||
| `projects` | |
|
||||
| `deals` | partitioned table |
|
||||
| `reminders` | |
|
||||
| `report_jobs` | |
|
||||
| `pd_processing_log` | |
|
||||
| `activity_log` | |
|
||||
| `balance_transactions` | |
|
||||
| `failed_webhook_jobs` | |
|
||||
| `rejected_deals_log` | |
|
||||
| `import_log` | |
|
||||
| `in_app_notifications` | |
|
||||
| `lead_charges` | also has `FORCE ROW LEVEL SECURITY` — even superuser subject |
|
||||
| `webhook_dedup_keys` | |
|
||||
| `outbound_webhook_subscriptions` | |
|
||||
| `outbound_webhook_deliveries` | |
|
||||
| `tenant_status_overrides` | |
|
||||
| `tenant_custom_domains` | |
|
||||
| `api_keys` | |
|
||||
| `push_subscriptions` | |
|
||||
| `comment_templates` | |
|
||||
| `deal_tags` | |
|
||||
| `import_unknown_statuses` | |
|
||||
| `webhook_log` | |
|
||||
| `tariff_subscriptions` | |
|
||||
| `saas_invoices` | |
|
||||
| `saas_upd_documents` | |
|
||||
| `saas_transactions` | |
|
||||
| `refund_requests` | |
|
||||
| `tenant_consents` | |
|
||||
| `project_limit_adjustments` | |
|
||||
| `impersonation_tokens` | |
|
||||
| `tenant_operations_log` | |
|
||||
| `project_suppliers` | |
|
||||
| `auth_log` | |
|
||||
|
||||
**Total: 35 RLS-protected tables.**
|
||||
|
||||
Tables explicitly confirmed **NOT** protected by RLS (safe to access on default connection from cron):
|
||||
|
||||
| Table | Notes |
|
||||
|---|---|
|
||||
| `supplier_leads` | no RLS |
|
||||
| `supplier_projects` | no RLS |
|
||||
| `system_settings` | no RLS |
|
||||
| `saas_admin_users` | no RLS |
|
||||
| `incidents_log` | no RLS |
|
||||
| `supplier_csv_reconcile_log` | no RLS |
|
||||
| `project_supplier_links` | no RLS |
|
||||
| `supplier_sync_log` | no RLS |
|
||||
| `tenants` | no RLS |
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — Cron Commands and Queued Jobs Inventory
|
||||
|
||||
### Scheduled via `routes/console.php`
|
||||
|
||||
| Command / Job | Schedule | File |
|
||||
|---|---|---|
|
||||
| `projects:reset-delivered-today` | Daily 00:00 MSK | `ResetDeliveredTodayCommand.php` |
|
||||
| `projects:reset-monthly` | Monthly 1st 00:00 MSK | `ResetMonthlyCountersCommand.php` |
|
||||
| `partitions:create-months` | Daily | `PartitionsCreateMonths.php` |
|
||||
| `RefreshSupplierSessionJob` | Hourly + daily 17:45 | `Supplier/RefreshSupplierSessionJob.php` |
|
||||
| `SyncSupplierProjectsJob` | Daily 18:00 MSK | `Supplier/SyncSupplierProjectsJob.php` |
|
||||
| `CleanupInactiveSupplierProjectsJob` | Daily 02:00 MSK | `Supplier/CleanupInactiveSupplierProjectsJob.php` |
|
||||
| `supplier:retry-failed` | Hourly | `RetryFailedSupplierJobsCommand.php` |
|
||||
| `CsvReconcileJob` | Every 30 min | `Supplier/CsvReconcileJob.php` |
|
||||
| `incidents:watch-failures` | Every 10 min | `IncidentsWatchFailures.php` |
|
||||
|
||||
### Not currently scheduled (exist in codebase, run ad-hoc or via Windows Task Scheduler separately)
|
||||
|
||||
| Command | File |
|
||||
|---|---|
|
||||
| `reminders:dispatch-due` | `RemindersDispatchDue.php` |
|
||||
| `reports:cleanup-expired` | `ReportsCleanupExpired.php` |
|
||||
| `supplier:check-webhook-secret` | `CheckSupplierWebhookSecretCommand.php` |
|
||||
| `supplier:import-projects` | `ImportSupplierProjectsCommand.php` |
|
||||
| `supplier:session:refresh` | `SupplierSessionRefreshCommand.php` |
|
||||
|
||||
### Queued jobs (dispatched from HTTP / other jobs)
|
||||
|
||||
| Job file | Dispatched from |
|
||||
|---|---|
|
||||
| `GenerateReportJob.php` | `ReportJobController` (HTTP) |
|
||||
| `ImportLeadsJob.php` | `ImportController` (HTTP) |
|
||||
| `ProcessWebhookJob.php` | `WebhookController` (HTTP) |
|
||||
| `RouteSupplierLeadJob.php` | `CsvReconcileJob`, `ProcessWebhookJob` |
|
||||
| `SyncSupplierProjectJob.php` | `SyncSupplierProjectsJob` |
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — Findings
|
||||
|
||||
### 3.1 Summary
|
||||
|
||||
| Verdict | Count |
|
||||
|---|---|
|
||||
| ❌ GAP | 4 |
|
||||
| ⚠️ AMBIGUOUS | 0 |
|
||||
| ✅ SAFE | 14 |
|
||||
| ➖ N/A (no RLS tables touched) | 2 |
|
||||
| **Total analyzed** | **20** |
|
||||
|
||||
*(10 command files + 10 job files)*
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Full Audit Matrix
|
||||
|
||||
| File | DB tables touched | Connection used | Verdict | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **COMMANDS** | | | | |
|
||||
| `ResetDeliveredTodayCommand.php` | `projects` (UPDATE) | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | Explicit `DB::connection('pgsql_supplier')` |
|
||||
| `ResetMonthlyCountersCommand.php` | `tenants`, `projects` (UPDATE) | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | Explicit `DB::connection('pgsql_supplier')` |
|
||||
| `PartitionsCreateMonths.php` | `deals`, `supplier_lead_costs` partition DDL | default (pg_class + DDL) | ✅ SAFE | DDL (`CREATE TABLE PARTITION OF`) is not subject to RLS policy evaluation |
|
||||
| `RemindersDispatchDue.php` | `reminders` (SELECT), `reminders` (UPDATE in loop) | **default** (no tenant set) | ❌ **GAP** | See finding RLS-01 |
|
||||
| `ReportsCleanupExpired.php` | `report_jobs` (SELECT/UPDATE), `pd_processing_log` (INSERT) | **default** (no tenant set) | ❌ **GAP** | See finding RLS-02 |
|
||||
| `IncidentsWatchFailures.php` | `failed_webhook_jobs`, `incidents_log`, `saas_admin_users` | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | Hotfixed 22.05.2026 |
|
||||
| `RetryFailedSupplierJobsCommand.php` | `failed_webhook_jobs` (SELECT/UPDATE) | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | Explicit `DB::connection('pgsql_supplier')` |
|
||||
| `CheckSupplierWebhookSecretCommand.php` | `system_settings` | default | ➖ N/A | `system_settings` has no RLS; deploy-time only |
|
||||
| `ImportSupplierProjectsCommand.php` | `users` (via `User::on('pgsql_supplier')`), `supplier_*` | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | Uses `Model::on('pgsql_supplier')` |
|
||||
| `SupplierSessionRefreshCommand.php` | none (dispatches job, no DB ops) | — | ➖ N/A | Only dispatches `RefreshSupplierSessionJob` |
|
||||
| **JOBS** | | | | |
|
||||
| `GenerateReportJob.php` | `report_jobs` (SELECT + multiple UPDATE) | **default** (no tenant set) | ❌ **GAP** | See finding RLS-03 |
|
||||
| `ImportLeadsJob.php` | `import_log`, `supplier_leads`, `deals`, `balance_transactions` | default, wrapped in `DB::transaction` + `SET LOCAL app.current_tenant_id` | ✅ SAFE | Correct pattern: SET LOCAL inside transaction |
|
||||
| `ProcessWebhookJob.php` | `webhook_dedup_keys`, `projects`, `supplier_leads`, `deals`, `balance_transactions` (handle); `failed_webhook_jobs` (failed()) | handle: `DB::transaction` + `SET LOCAL`; failed(): **default, no tenant** | ❌ **GAP** | See finding RLS-04 |
|
||||
| `RouteSupplierLeadJob.php` | `supplier_leads`, `deals`, `balance_transactions`, `failed_webhook_jobs` | `pgsql_supplier` for failed(); `SET LOCAL` for deal creation | ✅ SAFE | `failed()` uses `DB::connection('pgsql_supplier')` |
|
||||
| `SyncSupplierProjectJob.php` | `supplier_projects`, `supplier_sync_log`, `project_supplier_links` | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | All ops via `DB::connection('pgsql_supplier')` |
|
||||
| `Supplier/CleanupInactiveSupplierProjectsJob.php` | `supplier_projects`, `project_supplier_links` | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | |
|
||||
| `Supplier/CsvReconcileJob.php` | `supplier_csv_reconcile_log`, `supplier_leads` | `pgsql_supplier` for log; `supplier_leads` has no RLS | ✅ SAFE | |
|
||||
| `Supplier/DeleteSupplierProjectJob.php` | `supplier_projects`, `project_supplier_links` | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | |
|
||||
| `Supplier/RefreshSupplierSessionJob.php` | none (Redis/Cache only) | — | ➖ N/A | No DB operations |
|
||||
| `Supplier/SyncSupplierProjectsJob.php` | `supplier_projects`, `project_supplier_links` | `pgsql_supplier` (BYPASSRLS) | ✅ SAFE | |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Detailed Gap Findings
|
||||
|
||||
#### RLS-01 — `RemindersDispatchDue.php` → `reminders`
|
||||
|
||||
**Severity:** P1 (production crash on first run after deploy to `crm_app_user`)
|
||||
**File:** `app/app/Console/Commands/RemindersDispatchDue.php`
|
||||
**Trigger:** Windows Task Scheduler / cron, not in `routes/console.php` schedule
|
||||
|
||||
**Failing code (initial cross-tenant SELECT):**
|
||||
|
||||
```php
|
||||
$pending = Reminder::query()
|
||||
->where('is_sent', false)
|
||||
->whereNull('completed_at')
|
||||
->where('remind_at', '<=', $now)
|
||||
->orderBy('remind_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
```
|
||||
|
||||
The `Reminder` Eloquent model uses the default DB connection. On production (`crm_app_user`), executing any query on `reminders` when `app.current_tenant_id` GUC is not set throws:
|
||||
|
||||
```
|
||||
ERROR: unrecognized configuration parameter "app.current_tenant_id"
|
||||
```
|
||||
|
||||
Note: the per-tenant processing inside the loop correctly wraps individual operations in `DB::transaction()` with `SET LOCAL app.current_tenant_id = $tenantId`, but this does NOT protect the initial bulk SELECT that runs before any tenant context is established.
|
||||
|
||||
---
|
||||
|
||||
#### RLS-02 — `ReportsCleanupExpired.php` → `report_jobs` + `pd_processing_log`
|
||||
|
||||
**Severity:** P1 (production crash on first run)
|
||||
**File:** `app/app/Console/Commands/ReportsCleanupExpired.php`
|
||||
**Trigger:** Windows Task Scheduler / cron daily
|
||||
|
||||
**Sub-gap A — `report_jobs` SELECT:**
|
||||
|
||||
```php
|
||||
$jobs = ReportJob::query()
|
||||
->where('status', ReportJob::STATUS_DONE)
|
||||
->whereNotNull('file_path')
|
||||
->where('expires_at', '<', Carbon::now())
|
||||
->get();
|
||||
```
|
||||
|
||||
Uses default connection, `report_jobs` has RLS, no `app.current_tenant_id` set → crashes.
|
||||
|
||||
**Sub-gap B — `pd_processing_log` INSERT (via `PdAuditLogger`):**
|
||||
|
||||
```php
|
||||
app(PdAuditLogger::class)->record(
|
||||
event: PdAuditEvent::REPORT_FILE_DELETED,
|
||||
...
|
||||
);
|
||||
// PdAuditLogger::record() does:
|
||||
DB::table('pd_processing_log')->insert([...]);
|
||||
```
|
||||
|
||||
`PdAuditLogger` always uses the default DB connection. `pd_processing_log` has RLS. Called from cron without tenant context → crashes on INSERT.
|
||||
|
||||
The `->update(['status' => ReportJob::STATUS_DELETED])` inside the loop would also fail on production for the same reason (default connection, no GUC).
|
||||
|
||||
---
|
||||
|
||||
#### RLS-03 — `GenerateReportJob.php` → `report_jobs`
|
||||
|
||||
**Severity:** P1 (production crash on every report generation request)
|
||||
**File:** `app/app/Jobs/GenerateReportJob.php`
|
||||
**Trigger:** Dispatched from HTTP controller (`ReportJobController`) → processed by queue worker in separate process with fresh DB connection
|
||||
|
||||
**Failing code:**
|
||||
|
||||
```php
|
||||
public function handle(...): void
|
||||
{
|
||||
$job = ReportJob::query()->find($this->reportJobId);
|
||||
// ^ hits report_jobs on DEFAULT connection, no app.current_tenant_id set
|
||||
|
||||
if (!$job) { return; }
|
||||
|
||||
$job->update(['status' => ReportJob::STATUS_PROCESSING]);
|
||||
// ^ same issue
|
||||
|
||||
// ... generates report ...
|
||||
|
||||
$job->update([
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => ...,
|
||||
'expires_at' => ...,
|
||||
]);
|
||||
// ^ same issue
|
||||
}
|
||||
```
|
||||
|
||||
The job constructor receives only `$reportJobId: int`. There is no `$tenantId` field and no `SET LOCAL app.current_tenant_id` anywhere in the file. The queue worker process starts with a fresh DB connection where the GUC is absent → first `ReportJob::query()->find()` throws on production.
|
||||
|
||||
---
|
||||
|
||||
#### RLS-04 — `ProcessWebhookJob::failed()` → `failed_webhook_jobs`
|
||||
|
||||
**Severity:** P1 (silently fails to log webhook failures on production; the original `handle()` failure is thus unrecorded, masking outages)
|
||||
**File:** `app/app/Jobs/ProcessWebhookJob.php`
|
||||
**Trigger:** Queue worker — called by Laravel when `handle()` exhausts retries
|
||||
|
||||
**Failing code (in `failed()` callback):**
|
||||
|
||||
```php
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
DB::table('failed_webhook_jobs')->insert([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'webhook_source' => $this->webhookSource,
|
||||
'payload' => json_encode($this->payload),
|
||||
'exception' => $e->getMessage(),
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
The code uses `DB::table()` (default connection). A comment in the file suggests this was intentional to avoid RLS filtering, but on production `crm_app_user` is NOT BYPASSRLS — even `DB::table()` (raw query builder) goes through the same RLS policy that calls `current_setting('app.current_tenant_id')`. Without the GUC being set, PostgreSQL throws.
|
||||
|
||||
**Contrast with RouteSupplierLeadJob**, which correctly uses:
|
||||
|
||||
```php
|
||||
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([...]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — Recommended Fixes
|
||||
|
||||
### Fix RLS-01: `RemindersDispatchDue.php`
|
||||
|
||||
Replace the initial Eloquent SELECT with a raw cross-tenant query via BYPASSRLS connection, then add `$tenantId` context to the per-reminder processing:
|
||||
|
||||
```php
|
||||
// BEFORE (broken on prod):
|
||||
$pending = Reminder::query()
|
||||
->where('is_sent', false)
|
||||
->whereNull('completed_at')
|
||||
->where('remind_at', '<=', $now)
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
// AFTER (safe):
|
||||
$pending = DB::connection('pgsql_supplier')
|
||||
->table('reminders')
|
||||
->where('is_sent', false)
|
||||
->whereNull('completed_at')
|
||||
->where('remind_at', '<=', $now)
|
||||
->orderBy('remind_at')
|
||||
->limit($limit)
|
||||
->get(); // returns stdClass rows, not Eloquent models
|
||||
```
|
||||
|
||||
The existing per-tenant loop structure (with `SET LOCAL` inside `DB::transaction()`) can remain largely intact; the loop variable becomes a plain object. Alternatively, add `tenant_id` to the SELECT and group by tenant before the loop.
|
||||
|
||||
---
|
||||
|
||||
### Fix RLS-02: `ReportsCleanupExpired.php`
|
||||
|
||||
**Sub-fix A — `report_jobs`:** Use `ReportJob::on('pgsql_supplier')` or raw BYPASSRLS query:
|
||||
|
||||
```php
|
||||
// BEFORE:
|
||||
$jobs = ReportJob::query()->where(...)->get();
|
||||
|
||||
// AFTER:
|
||||
$jobs = DB::connection('pgsql_supplier')
|
||||
->table('report_jobs')
|
||||
->where('status', ReportJob::STATUS_DONE)
|
||||
->whereNotNull('file_path')
|
||||
->where('expires_at', '<', Carbon::now())
|
||||
->get();
|
||||
```
|
||||
|
||||
**Sub-fix B — `pd_processing_log`:** Extend `PdAuditLogger::record()` to accept an optional `$connection` parameter, defaulting to `'pgsql_supplier'` for cron callers:
|
||||
|
||||
```php
|
||||
// Option 1: pass connection to PdAuditLogger
|
||||
app(PdAuditLogger::class)->record(
|
||||
event: PdAuditEvent::REPORT_FILE_DELETED,
|
||||
connection: 'pgsql_supplier',
|
||||
...
|
||||
);
|
||||
|
||||
// Option 2 (simpler for cron): suppress pd_processing_log in cleanup cron
|
||||
// (report file deletion is an operational action, not a user-facing PD event)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fix RLS-03: `GenerateReportJob.php`
|
||||
|
||||
Add `$tenantId` as a constructor parameter and wrap all `ReportJob` operations in a tenant-scoped transaction:
|
||||
|
||||
```php
|
||||
public function __construct(
|
||||
private readonly int $reportJobId,
|
||||
private readonly int $tenantId, // ADD THIS
|
||||
) {}
|
||||
|
||||
public function handle(...): void
|
||||
{
|
||||
DB::transaction(function () use (...) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenantId);
|
||||
|
||||
$job = ReportJob::query()->find($this->reportJobId);
|
||||
if (!$job) { return; }
|
||||
|
||||
$job->update(['status' => ReportJob::STATUS_PROCESSING]);
|
||||
// ... generate report ...
|
||||
$job->update(['status' => ReportJob::STATUS_DONE, ...]);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The dispatch site (`ReportJobController`) must pass `$tenantId` when dispatching:
|
||||
|
||||
```php
|
||||
// In ReportJobController:
|
||||
GenerateReportJob::dispatch($reportJob->id, auth()->user()->tenant_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fix RLS-04: `ProcessWebhookJob::failed()`
|
||||
|
||||
Follow the `RouteSupplierLeadJob` precedent — use BYPASSRLS connection:
|
||||
|
||||
```php
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
// BEFORE: DB::table('failed_webhook_jobs')->insert([...]);
|
||||
// AFTER:
|
||||
DB::connection('pgsql_supplier')
|
||||
->table('failed_webhook_jobs')
|
||||
->insert([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'webhook_source' => $this->webhookSource,
|
||||
'payload' => json_encode($this->payload),
|
||||
'exception' => $e->getMessage(),
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section 5 — Risk Assessment
|
||||
|
||||
| Finding | Probability of prod crash | Impact | Priority |
|
||||
|---|---|---|---|
|
||||
| RLS-01 `RemindersDispatchDue` | HIGH — runs daily via Task Scheduler | Reminders never sent on prod | **P1** |
|
||||
| RLS-02 `ReportsCleanupExpired` | HIGH — runs daily | Disk not cleaned; PD audit log broken | **P1** |
|
||||
| RLS-03 `GenerateReportJob` | HIGH — every report request | All report downloads fail silently | **P1** |
|
||||
| RLS-04 `ProcessWebhookJob::failed()` | HIGH — every webhook failure event | Webhook failures unlogged; `incidents:watch-failures` sees 0 failures; incidents masked | **P1** |
|
||||
|
||||
All four findings are P1. On production with `crm_app_user`, every single occurrence of these code paths will crash (RLS-01/02/03) or silently fail to write (RLS-04), with the crash cascading to undetectable outages.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — Files Analyzed (20 total)
|
||||
|
||||
**Commands (10):**
|
||||
|
||||
1. `app/Console/Commands/ResetDeliveredTodayCommand.php` ✅
|
||||
2. `app/Console/Commands/ResetMonthlyCountersCommand.php` ✅
|
||||
3. `app/Console/Commands/PartitionsCreateMonths.php` ✅
|
||||
4. `app/Console/Commands/RemindersDispatchDue.php` ❌ RLS-01
|
||||
5. `app/Console/Commands/ReportsCleanupExpired.php` ❌ RLS-02
|
||||
6. `app/Console/Commands/IncidentsWatchFailures.php` ✅
|
||||
7. `app/Console/Commands/RetryFailedSupplierJobsCommand.php` ✅
|
||||
8. `app/Console/Commands/CheckSupplierWebhookSecretCommand.php` ➖
|
||||
9. `app/Console/Commands/ImportSupplierProjectsCommand.php` ✅
|
||||
10. `app/Console/Commands/SupplierSessionRefreshCommand.php` ➖
|
||||
|
||||
**Jobs (10):**
|
||||
|
||||
1. `app/Jobs/GenerateReportJob.php` ❌ RLS-03
|
||||
2. `app/Jobs/ImportLeadsJob.php` ✅
|
||||
3. `app/Jobs/ProcessWebhookJob.php` ❌ RLS-04 (failed() method only)
|
||||
4. `app/Jobs/RouteSupplierLeadJob.php` ✅
|
||||
5. `app/Jobs/SyncSupplierProjectJob.php` ✅
|
||||
6. `app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php` ✅
|
||||
7. `app/Jobs/Supplier/CsvReconcileJob.php` ✅
|
||||
8. `app/Jobs/Supplier/DeleteSupplierProjectJob.php` ✅
|
||||
9. `app/Jobs/Supplier/RefreshSupplierSessionJob.php` ➖
|
||||
10. `app/Jobs/Supplier/SyncSupplierProjectsJob.php` ✅
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Раздел карты:** C1 «Маркетинг и привлечение»
|
||||
**Версия:** 1.0 от 22.05.2026
|
||||
**Кросс-ссылки:** [Tooling §4.49–58](../Tooling_v8_3.md) · [ADR-015](../adr/015-c1-marketing-tooling.md) · [Spec](../superpowers/specs/2026-05-22-c1-marketing-tooling-design.md) · [Plan](../superpowers/plans/2026-05-22-c1-marketing-tooling.md) · [marketing-vet.md](../security/marketing-vet.md)
|
||||
**Кросс-ссылки:** [Tooling §4.49–58](../Tooling_v8_3.md) · [ADR-015](../adr/015-marketing-tooling.md) · [Spec](../superpowers/specs/2026-05-22-c1-marketing-tooling-design.md) · [Plan](../superpowers/plans/2026-05-22-c1-marketing-tooling.md) · [marketing-vet.md](../security/marketing-vet.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -35,10 +35,12 @@
|
||||
| 81 | Postiz MCP | `postiz-mcp` (лицензия под ветом) | Закомментированный skeleton `_comment_postiz_skeleton` |
|
||||
|
||||
**Что нужно от заказчика перед использованием #78/#79:**
|
||||
|
||||
1. Получить OAuth-токен на [oauth.yandex.ru](https://oauth.yandex.ru), приложение с доступом к Метрике/Директ (scope read-only).
|
||||
2. Добавить в `.env.local` (gitignored): `YANDEX_OAUTH_TOKEN=y0_AgA...`
|
||||
|
||||
**Что нужно для #80 (Telegram):**
|
||||
|
||||
1. Зарегистрировать выделенный Telegram-аккаунт для Лидерры (не личный).
|
||||
2. Получить `TELEGRAM_API_ID` и `TELEGRAM_API_HASH` на [my.telegram.org/apps](https://my.telegram.org/apps).
|
||||
3. Сгенерировать `TELEGRAM_SESSION_STRING` один раз через GramJS или Telethon.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"last_read_at": "2026-05-19T00:00:00+03:00",
|
||||
"read_count_last_period": 0,
|
||||
"last_read_at": "2026-05-23T08:47:32.141Z",
|
||||
"read_count_last_period": 1,
|
||||
"period_start": "2026-05-19T00:00:00+03:00"
|
||||
}
|
||||
|
||||
+16
-8
@@ -1,22 +1,30 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-22T15:11:41.534Z
|
||||
Last updated: 2026-05-23T16:38:59.719Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| C1 L1-watcher | 🔴 | If the plugin is referenced in Tooling under a group/human name, add an alias to tools/.l1-watcher-aliases.txt. |
|
||||
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
|
||||
| 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 | ⚠️ | 76 episode(s) this month · Stop-hook + post-commit OK · 28 missed activation(s) — see /brain-retro |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 15 chains in sync |
|
||||
| C5 Observer-coverage | ⚠️ | 165 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 76 episodes this month, 0 observer_error markers, 23 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 5
|
||||
- Last /brain-retro: 3 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 28. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
- Observer evidence: 165 episodes this month, 0 observer_error markers, 83 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 26
|
||||
- Last /brain-retro: 0 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
|
||||
- Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
|
||||
- Этап 2 (измерения + классификатор-парсер) ⏸ ждёт «продолжаем» от заказчика. Plan: TBD.
|
||||
- Этап 3 (принуждение — хук на routing) — не начат.
|
||||
- Этап 4 (уборка правил) — не начат.
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
|
||||
- Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
|
||||
- Этап 2 (измерения + классификатор-парсер) ⏸ ждёт «продолжаем» от заказчика. Plan: TBD.
|
||||
- Этап 3 (принуждение — хук на routing) — не начат.
|
||||
- Этап 4 (уборка правил) — не начат.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,226 @@
|
||||
# Brain-retro #3 — весь май 2026 (полный срез)
|
||||
|
||||
**Дата:** 2026-05-23 (~11:50 MSK).
|
||||
**Период:** весь май 2026 — 2026-05-19T05:18Z .. 2026-05-23T08:47Z (121 строк JSONL; 116 v2 + 5 v1 пропущено).
|
||||
**Анализатор:** `node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl` + `tools/missed-activations.mjs`.
|
||||
**Уровень анализа:** обзорный по запросу заказчика; экономия 100%.
|
||||
**Отношение к предыдущему ретро:** надстройка над [2026-05-20-brain-retro-v2.md](2026-05-20-brain-retro-v2.md) (23 v2-эпизода, 2026-05-20T17:55 MSK). Здесь — дельта в 105 v2-эпизодов (22 task_id) после cutoff 2026-05-20T08:58:44Z, итого 116 v2 + 61 task_ref.
|
||||
|
||||
> `episodeCount=116`, `v1SkippedCount=5`, `observerErrorCount=0`. Цифры по 116 v2-эпизодам, если не отмечено иное.
|
||||
|
||||
---
|
||||
|
||||
## Period & context
|
||||
|
||||
19.05–23.05.2026 (5 дней) — самый плотный 5-дневный спринт мая. Параллельно шли:
|
||||
|
||||
- **A8 infosec-tooling** (21.05): #68 ZAP + #70 Ward установлены портативно; push `3fc5501`. Открытые эндпоинты закрыты `2a34ee8` + SSRF-гард `6933ddc`.
|
||||
- **C1 marketing-tooling** (22.05): 10 узлов #74-83, push `a0e47bc6`; нормативка v1.39/v2.27/v2.23/v3.22.
|
||||
- **pg_audit#28 + pg_anonymizer#29** на проде liderra.ru (22.05): push `527a779`.
|
||||
- **Audit journaling closure** (22.05, 9+ дыр): P0+P1 done, push `3f7c1e40`, 22 коммита, выкачено на прод.
|
||||
- **Серверный hardening** (22.05 по SSH): HTTPS+HSTS, fail2ban, бэкапы cron, ModSecurity CRS DetectionOnly. SEC-3/SEC-5 ждут YC-консоль.
|
||||
- **Регистрация email+phone** (22.05): фича в feat/test-deploy `0e31783`, на проде Yandex 360 SMTP.
|
||||
- **7 дыр аудита follow-up** (23.05): #7 (RLS dev↔prod) + #1 (hash-chain validator) DONE+на проде; lefthook починен.
|
||||
- **QA-прогон чек-листа** (23.05): 5 qa-tenants 11-15, B-01 by-design, два деплоя.
|
||||
|
||||
---
|
||||
|
||||
## Macro метрики дельты (vs ретро #2)
|
||||
|
||||
| метрика | ретро #2 | ретро #3 | дельта |
|
||||
|---|---|---|---|
|
||||
| v2-эпизоды (накопл.) | 23 | 116 | +93 |
|
||||
| уникальных task_id | 7 | 61 | +54 |
|
||||
| skill-инвокации | 6 | 19 | +13 |
|
||||
| observer_error | 0 | 0 | — |
|
||||
| schema_version v1 skipped | 5 | 5 | — |
|
||||
|
||||
Сильный рост скил-инвокаций в дельте (+13: writing-plans×3, systematic-debugging×3, TDD×3, regression×1, verify×1, security-go-live×1, brainstorming×1, dispatching-parallel-agents×1, executing-plans×1, process-analysis×1, verification-before-completion×1). Дисциплина выросла — спринты A8/C1/audit-journaling шли через структурированные skills.
|
||||
|
||||
---
|
||||
|
||||
## Path-type distribution (n=116)
|
||||
|
||||
| path_type | count | % |
|
||||
|---|---|---|
|
||||
| improvised | 95 | 81.9% |
|
||||
| regulated | 16 | 13.8% |
|
||||
| mixed | 4 | 3.4% |
|
||||
| alternative | 1 | 0.9% |
|
||||
|
||||
Regulated +0.8 п.п. vs ретро #2 (13.0 → 13.8%) — рост в абсолютных числах в 5×.
|
||||
|
||||
---
|
||||
|
||||
## Outcome (inferred) distribution
|
||||
|
||||
| outcome | count | % |
|
||||
|---|---|---|
|
||||
| soft_success | 53 | 45.7% |
|
||||
| success | 38 | 32.8% |
|
||||
| unknown (хвост сессий) | 23 | 19.8% |
|
||||
| blocked | 2 | 1.7% |
|
||||
|
||||
`prompt_signal` сигналов: 42 new_task / 65 neutral / 7 approval / 2 **correction** (1.7% rework rate — здоровый низкий уровень).
|
||||
|
||||
---
|
||||
|
||||
## Factor matrix highlights
|
||||
|
||||
### decision_provenance — кто решает?
|
||||
|
||||
| provenance | count | success | soft_success | blocked |
|
||||
|---|---|---|---|---|
|
||||
| autonomous | 86 | 27 | 36 | 2 |
|
||||
| user_directed_method | 3 | 1 | 2 | — |
|
||||
| user_chose_from_options | 27 | 10 | 15 | — |
|
||||
|
||||
`user_chose_from_options=27` — сильный паттерн collaborative-choice (A/B/C → выбор заказчика). `user_directed_method` остаётся редким (3, healthy — заказчик НЕ навязывает методы).
|
||||
|
||||
### economy_level
|
||||
|
||||
| economy_level | success | soft_success | blocked |
|
||||
|---|---|---|---|
|
||||
| null | 4 | 2 | — |
|
||||
| 0 | 1 | — | 1 |
|
||||
| 5 | 4 | 1 | — |
|
||||
| 100 | 29 | 50 | 1 |
|
||||
|
||||
Доминирует уровень 100 (стандарт); `0` дал единственный blocked. Никаких аномалий.
|
||||
|
||||
### post_compaction × session_segment
|
||||
|
||||
Post-compaction эпизодов 43, исходов нормально (14 success / 22 soft_success). Late-segment всего 11 — длинные сессии редки.
|
||||
|
||||
---
|
||||
|
||||
## Missed activations (Pravila §16.4 v1.36 conditional rule)
|
||||
|
||||
**Total: 40** (из 44 v2-эпизодов с непустым классификационным мэппингом, `node_chosen='direct'`; экспект-узлы все non-dormant).
|
||||
|
||||
### By classification
|
||||
|
||||
| classification | episodes | bypassed nodes |
|
||||
|---|---|---|
|
||||
| question | 17 | #60 context7 |
|
||||
| memory-sync | 8 | #33 claude-md-management |
|
||||
| feature | 5 | #19 Superpowers |
|
||||
| bugfix | 4 | #18 Pest, #34 Sentry |
|
||||
| planning | 2 | #19, #41 CCPM, #42 product-management |
|
||||
| refactor | 1 | #11/#12/#43/#64/#65 |
|
||||
| cleanup | 1 | #11/#12 |
|
||||
| monitoring | 1 | #34/#35 Redis |
|
||||
| analysis | 1 | #25/#39/#53 |
|
||||
|
||||
### Анализ — ЧТО ИЗ ЭТОГО реально промах vs шум классификатора
|
||||
|
||||
- **#60 context7 (17 question)** — большая часть «question» в логах это разговорные уточнения с заказчиком («что выбираем», «а ты пробовал», «делай»), **не** library-docs-вопросы. Текущий маппинг `question → [#60]` слишком широкий. **Кандидат:** сузить — либо разделить классификатор (`question_lib_docs` vs `question_conversational`), либо снять `#60` из маппинга и оставить пустой массив до появления узкого классификатора. Сейчас 17 «промахов» — шум.
|
||||
- **#33 claude-md-management (8 memory-sync)** — `memory-sync` в эпизодах = правки `memory/*.md` (auto-memory system), а **не** правки CLAUDE.md. `#33` относится к `CLAUDE.md` (§5 п.10 hard-rule), не к memory-файлам. **Кандидат:** очистить маппинг `memory-sync → []` ИЛИ переименовать в `claude-md-edit` и оставить только для правок CLAUDE.md.
|
||||
- **#19 Superpowers (5 feature + 2 planning)** — это **реальные промахи**: feature-задачи без `brainstorming` / `writing-plans`. Сверка по дельте: новых features в дельте было ~5 (A8 install, C1 plugin enable, pg_audit setup и т.д.) — большинство шли autonomous direct. Возможно стоит фиксировать, особенно после Pravila §12 hard-rule. **Кандидат:** мягкий сигнал в STATUS.md (не дальше), решение за заказчиком.
|
||||
- **#18 Pest / #34 Sentry (4 bugfix)** — Pest для bugfix очевиден, но Sentry на проде ещё не деплоен (Б-1). #34 в DEFERRED не записан, но фактически бесполезен. **Кандидат:** проверить `tools/.node-dormancy.json` — должен ли #34 быть `dormant: true` до Sentry-деплоя?
|
||||
|
||||
### Кандидаты на пересмотр observer-classification-map.json
|
||||
|
||||
| key | текущее значение | предлагаемая правка | обоснование |
|
||||
|---|---|---|---|
|
||||
| `question` | `["#60"]` | `[]` | разговорные вопросы ≠ library-docs-lookup; ложноположительных 17×, прав один-два максимум |
|
||||
| `memory-sync` | `["#33"]` | `[]` | #33 канал ТОЛЬКО для CLAUDE.md (§5 п.10), а не memory/*.md (auto-memory не пинует через #33) |
|
||||
| `bugfix` | `["#18","#34"]` | оставить или `["#18"]` пока Sentry не работает | проверить, не стоит ли пометить #34 dormant до Б-1 |
|
||||
|
||||
---
|
||||
|
||||
## Causal chains
|
||||
|
||||
23 цепочки shared-files обнаружено (≥5 минутный интервал, общие файлы в task_size). Ключевые:
|
||||
|
||||
- **`ЭТАЛОН.md`** — 6+ цепочек 20.05 (правки эталона за день). Ожидаемо — день большого обновления эталона.
|
||||
- **`SyncSupplierProjectJob.php` / `SyncSupplierProjectsJob.php`** — цепочка 20→22.05 (Plan 5 supplier-sync fix → retry-storm fix `0c9357a`).
|
||||
- **`AppLayout.vue`** — 20.05 две правки.
|
||||
|
||||
Нет «error→fix loop» цепочек, которые бы указывали на повторяющийся баг.
|
||||
|
||||
---
|
||||
|
||||
## Skill invocations (delta, n=13)
|
||||
|
||||
| skill | times |
|
||||
|---|---|
|
||||
| superpowers:writing-plans | 3 |
|
||||
| superpowers:systematic-debugging | 3 |
|
||||
| superpowers:test-driven-development | 3 |
|
||||
| superpowers:verification-before-completion | 1 |
|
||||
| superpowers:brainstorming | 1 |
|
||||
| superpowers:dispatching-parallel-agents | 1 |
|
||||
| superpowers:executing-plans | 1 |
|
||||
| regression | 1 |
|
||||
| verify | 1 |
|
||||
| security-go-live | 1 |
|
||||
| process-analysis | 1 |
|
||||
| brain-retro | 0 (сейчас 1, после записи) |
|
||||
|
||||
Покрытие L1-L15 chain'ов: L1=4, L8=4, L15=1, L3=1 (security-go-live). Большая часть — direct.
|
||||
|
||||
---
|
||||
|
||||
## Errors / retries / time_burn (delta)
|
||||
|
||||
133 errors / 116 retries / 17 time_burn events. Кажется много, но распределено по 105 эпизодов — в среднем ~1.3 error/episode. Спринты A8 install (curl/tar quirks), pg_audit build (Rust/pgrx), audit journaling (миграции), 7-дыр follow-up (lefthook quirks) генерировали много retry в Bash без скрытых проблем.
|
||||
|
||||
---
|
||||
|
||||
## Candidates for owner review
|
||||
|
||||
> Все ниже — кандидаты, не правки. Применять только по явному «делай» от заказчика.
|
||||
|
||||
### A. observer-classification-map.json (`tools/observer-classification-map.json`)
|
||||
|
||||
**A1.** `question → []` (сейчас `["#60"]`). Сузить классификатор или снять #60.
|
||||
|
||||
- **Why:** 17 разговорных question-эпизодов ловятся как missed-activation к context7. Шум.
|
||||
- **Rejection-option:** оставить как есть и считать missed-activations информационным шумом, не сигналом.
|
||||
|
||||
**A2.** `memory-sync → []` (сейчас `["#33"]`).
|
||||
|
||||
- **Why:** #33 claude-md-management — канал ТОЛЬКО для CLAUDE.md (Pravila §5 п.10), а не memory/*.md. Auto-memory system пинует напрямую.
|
||||
- **Rejection-option:** переименовать классификатор в `claude-md-edit` и сохранить #33.
|
||||
|
||||
### B. node-dormancy.json (`tools/.node-dormancy.json`)
|
||||
|
||||
**B1.** Проверить #34 Sentry MCP — должен ли быть `dormant: true` до Б-1 (Sentry instance не задеплоен на проде, использовать нельзя).
|
||||
|
||||
- **Why:** missed-activation для bugfix включает #34, но #34 фактически нерабочий до Б-1.
|
||||
- **Rejection-option:** оставить — Sentry MCP установлен в Claude и теоретически доступен; «прод не задеплоен» не равно «инструмент dormant».
|
||||
|
||||
### C. STATUS.md C5 missed-activations
|
||||
|
||||
**C1.** Surface 40 missed activations с разбивкой по классификации в STATUS.md (текущий статус-генератор уже это умеет — после обновления маппинга цифра упадёт до ~15).
|
||||
|
||||
- **Why:** наглядная метрика «промахов роутинга» в дашборде.
|
||||
- **Rejection-option:** не surface, оставить только в brain-retro заметках.
|
||||
|
||||
### D. Pravila §12 — feature без Superpowers
|
||||
|
||||
**D1.** Зафиксировать в feedback-memory правило «feature/planning-задачи ИДУТ через Superpowers writing-plans, даже если задача кажется простой» — сейчас 7 feature/planning-эпизодов в дельте прошли direct.
|
||||
|
||||
- **Why:** Pravila §12 hard-rule предписывает skill-инвокацию первой для 14 типов; feature/planning в списке.
|
||||
- **Rejection-option:** считать «autonomous direct для маленьких feature нормой», не фиксировать.
|
||||
|
||||
### E. Авто-обновление observer-classification-map после прочтения этого retro
|
||||
|
||||
- Маппинг живёт в `tools/observer-classification-map.json`. Кандидаты A1/A2 — однострочные правки.
|
||||
- НЕ автоматизирую — жду явного «делай A1 / делай A2 / делай оба».
|
||||
|
||||
---
|
||||
|
||||
## Behavioral rule check (Pravila §16.4)
|
||||
|
||||
- «Не использован ≠ проблема» — соблюдено: я различаю **capability-readiness** (`other` без рекомендаций, 69 эпизодов) от **missed activation** (40 эпизодов с маппингом + direct + non-dormant). Только последние сурфейсятся как сигнал.
|
||||
|
||||
---
|
||||
|
||||
## Что НЕ меняется этим retro
|
||||
|
||||
- НЕ редактирую `tools/observer-classification-map.json`, `tools/.node-dormancy.json`, STATUS.md политики, нормативку, code.
|
||||
- НЕ пишу в episodes-*.jsonl (read-only).
|
||||
- НЕ trigger'у auto-memory.
|
||||
- STATUS.md перегенерируется через `node tools/status-md-generator.mjs` (см. шаг 8a процедуры — выполняется ниже).
|
||||
@@ -0,0 +1,105 @@
|
||||
# Node Registry
|
||||
|
||||
Машиночитаемый реестр узлов тулчейна Лидерры — single source of truth для `router-procedure.md`, хуков enforcement'а (этапы 2-3 router discipline overhaul) и auto-rendered секций в нормативке.
|
||||
|
||||
## Файлы
|
||||
|
||||
- **`nodes.yaml`** — реестр 83 узлов + 16 цепочек L1-L16. Источник истины.
|
||||
- **`schema.json`** — JSON Schema, валидация `nodes.yaml` при загрузке.
|
||||
- **`README.md`** — этот файл.
|
||||
|
||||
## Как читать узел
|
||||
|
||||
```yaml
|
||||
- id: "#19" # уникальный идентификатор из Tooling Прил. Н §0
|
||||
name: "Superpowers v5.1.0"
|
||||
slug: "superpowers" # каноническое имя для invocation (kebab-ASCII)
|
||||
category: "phase-2" # phase-0 / phase-1 / phase-2 / phase-3 / off-phase
|
||||
subcategory: null # либо строка (architecture-tooling, debug-runtime, ...)
|
||||
status: "active" # active | dormant | deferred | historic
|
||||
dormancy_reason: null # null если active, иначе текст причины
|
||||
triggers: # как роутер выбирает узел
|
||||
- {classification: "feature", weight: 1.0}
|
||||
- {keyword: "tdd", weight: 1.0}
|
||||
- {file_pattern: "tests/**/*.php", weight: 1.0}
|
||||
boundaries: # связи с другими узлами (ADR, paired stack, replaces)
|
||||
- {adr: "ADR-011", role: "hard-floor source"}
|
||||
- {pair: "#30", relation: "paired stack"}
|
||||
chain_membership: ["L1", "L8"] # в каких L-цепочках участвует (sorted)
|
||||
attributes: # свободная map для прочих метаданных
|
||||
tooling_section: "§3.3 #19"
|
||||
install: "marketplace plugin"
|
||||
```
|
||||
|
||||
### Status маппинг
|
||||
|
||||
| Status | Что значит |
|
||||
|---|---|
|
||||
| `active` | Узел активно используется. |
|
||||
| `dormant` | Узел отключён/заменён без эквивалента. Артефакт реестра сохраняется (#17 pg_partman — заменён ручным cron'ом). |
|
||||
| `deferred` | Узел запланирован, но pending Б-1 / undeployed dependencies (#34 Sentry, #44 Figma, #67 NightOwl, #82 DataForSEO, #83 Unisender Go). |
|
||||
| `historic` | Узел заменён другим узлом реестра (`{pair: "#N", relation: "replaced by"}`). #1 PG MCP заменён #10 Boost. |
|
||||
|
||||
### Trigger типы
|
||||
|
||||
- `{keyword: "<lowercase trimmed>", weight}` — exact-match по фразе.
|
||||
- `{classification: "<class>", weight}` — соответствие классу задачи (feature/planning/bugfix/refactor/...).
|
||||
- `{file_pattern: "<glob>", weight}` — соответствие пути файла (`tests/**/*.php`).
|
||||
|
||||
Weight — number ∈ `[0, 1]`. По умолчанию 1.0.
|
||||
|
||||
### Boundaries
|
||||
|
||||
- `{adr: "ADR-XXX", role: "<role>"}` — узел связан с ADR-решением.
|
||||
- `{pair: "#N", relation: "<rel>"}` — узел связан с другим узлом реестра (`replaces`, `replaced by`, `paired stack`).
|
||||
- `{relation: "<text>"}` — свободная связь (правила PSR_v1, описательная роль).
|
||||
|
||||
## Как добавить новый узел
|
||||
|
||||
1. Получить новый `#N` из [Tooling Прил. Н §0](../Tooling_v8_3.md) (канон счётчика).
|
||||
2. Открыть `nodes.yaml`, добавить блок в массив `nodes:` (в правильное место по числовой сортировке).
|
||||
3. **Триггеры:** что должен сказать заказчик / какой класс задач включает узел. Lowercase, trimmed, без двоеточий.
|
||||
4. **Границы:** какие ADR разделяют узел от соседей, есть ли paired stack.
|
||||
5. Прогнать рендер: `node tools/registry-render.mjs` — должно перерендерить `Tooling §4.0` + `routing-off-phase` routing-table.
|
||||
6. Запустить тесты: `cd app && npx vitest --config vitest.config.tools.mjs run ../tools/registry-load.test.mjs`. Все должны быть GREEN.
|
||||
7. Закоммитить YAML + Tooling/routing-off-phase одним коммитом.
|
||||
|
||||
## Auto-render
|
||||
|
||||
`tools/registry-render.mjs` пишет в auto-region маркеры:
|
||||
|
||||
- `<!-- auto:tooling-registry-summary:begin -->` в `docs/Tooling_v8_3.md` §4.0 (краткая сводка 83 узлов).
|
||||
- `<!-- auto:routing-table:begin -->` в `docs/routing-off-phase.md` (routing-table по classifications).
|
||||
|
||||
**Не правьте содержимое между маркерами вручную** — оно перезатрётся при следующем рендере. Для изменения структуры таблицы — правьте `tools/registry-render.mjs` renderer functions.
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
node tools/registry-render.mjs # переписать файлы
|
||||
node tools/registry-render.mjs --check # exit 1 если drift (для lefthook)
|
||||
```
|
||||
|
||||
## Lefthook gate
|
||||
|
||||
`registry-render-check` — pre-commit job 17 в `lefthook.yml`. Триггерится на изменения `docs/registry/nodes.yaml` / `docs/Tooling_v8_3.md` / `docs/routing-off-phase.md`. **Warn-only первую неделю** (`if/then/fi` block, exit 0 даже при drift). Если видишь WARN — запусти:
|
||||
|
||||
```bash
|
||||
node tools/registry-render.mjs && git add docs/Tooling_v8_3.md docs/routing-off-phase.md
|
||||
```
|
||||
|
||||
После стабилизации (когда команда привыкнет к workflow) — убрать warn-fallback и сделать blocking.
|
||||
|
||||
## Цепочки L1-L16
|
||||
|
||||
16 канонических связок 2+ узлов (см. `chains:` секцию в `nodes.yaml`). Источник истины — [`docs/routing-off-phase.md`](../routing-off-phase.md) §4 (таблица L1-L16). При изменении routing-off-phase — обновляйте chains в `nodes.yaml` синхронно.
|
||||
|
||||
## Связано
|
||||
|
||||
- Spec: [`docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md)
|
||||
- Plan этап 1: [`docs/superpowers/plans/2026-05-23-router-overhaul-stage-1-registry.md`](../superpowers/plans/2026-05-23-router-overhaul-stage-1-registry.md)
|
||||
- Router procedure: [`docs/router-procedure.md`](../router-procedure.md) (5-шаговая процедура «task → node»)
|
||||
- Routing-off-phase: [`docs/routing-off-phase.md`](../routing-off-phase.md) (триггеры + L-цепочки)
|
||||
- ADR-011 — brain governance.
|
||||
- Pravila §15.2 — pre-flight sync для нормативных файлов.
|
||||
- Pure modules: `tools/registry-load.mjs` + `tools/registry-render.mjs` + tests `tools/registry-*.test.mjs`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://liderra.local/registry-schema.json",
|
||||
"title": "Liderra Node Registry",
|
||||
"type": "object",
|
||||
"required": ["version", "nodes", "chains"],
|
||||
"properties": {
|
||||
"version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/node" }
|
||||
},
|
||||
"chains": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^L\\d+$": { "$ref": "#/definitions/chain" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"node": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "slug", "category", "status", "triggers"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^#\\d+$" },
|
||||
"name": { "type": "string", "minLength": 3 },
|
||||
"slug": { "type": "string", "pattern": "^[a-z0-9-]+(:[a-z0-9-]+)*$" },
|
||||
"category": { "enum": ["phase-0", "phase-1", "phase-2", "phase-3", "off-phase"] },
|
||||
"subcategory": { "type": ["string", "null"] },
|
||||
"status": { "enum": ["active", "dormant", "deferred", "historic"] },
|
||||
"dormancy_reason": { "type": ["string", "null"] },
|
||||
"triggers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "type": "object", "required": ["keyword"], "properties": { "keyword": { "type": "string" }, "weight": { "type": "number", "minimum": 0, "maximum": 1 } }, "additionalProperties": false },
|
||||
{ "type": "object", "required": ["classification"], "properties": { "classification": { "type": "string" }, "weight": { "type": "number", "minimum": 0, "maximum": 1 } }, "additionalProperties": false },
|
||||
{ "type": "object", "required": ["file_pattern"], "properties": { "file_pattern": { "type": "string" }, "weight": { "type": "number", "minimum": 0, "maximum": 1 } }, "additionalProperties": false }
|
||||
]
|
||||
}
|
||||
},
|
||||
"boundaries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"adr": { "type": "string", "pattern": "^ADR-\\d{3}$" },
|
||||
"pair": { "type": "string", "pattern": "^#\\d+$" },
|
||||
"relation": { "type": "string" },
|
||||
"role": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"chain_membership": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "pattern": "^L\\d+$" }
|
||||
},
|
||||
"attributes": { "type": "object" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"chain": {
|
||||
"type": "object",
|
||||
"required": ["name", "sequence"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"sequence": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
|
||||
"triggers": { "type": "array" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-63
@@ -21,69 +21,18 @@
|
||||
|
||||
## Таблица routing
|
||||
|
||||
| Триггер задачи | Узел | # | Категория | Гейт |
|
||||
|---|---|---|---|---|
|
||||
| Архитектурное решение, ADR, обоснование выбора | **adr-kit** | #36 | architecture-tooling | `adr-judge` в lefthook job 9 |
|
||||
| C4 / контекст / контейнер / компонент-диаграмма | **mermaid-skill** | #37 | architecture-tooling | вендорен; рендера не нужно |
|
||||
| Справка по архитектурному паттерну (Clean/Hex/DDD/CQRS…) | **architecture-patterns** | #38 | architecture-tooling | knowledge-only |
|
||||
| Контроль направления зависимостей / границ слоёв `App\` | **deptrac** | #43 | architecture-tooling | lefthook pre-commit job 10 |
|
||||
| Security-аудит diff/PR, supply-chain риск, вариант-анализ | **Trail of Bits Skills** (8 плагинов) | #39 | audit-security | on-demand кампания |
|
||||
| Inline-предупреждения уязвимостей при правке кода | **Security Guidance** (PreToolUse-хук) | #40 | audit-security | блокирующий `sys.exit 2` |
|
||||
| SAST-сканер всего кода | **Semgrep MCP** | #25 (фаза 3) | — | npm run sast |
|
||||
| Полный security-review текущей ветки | `/security-review` (slash-команда) | — | audit-security | customized FP-фильтр |
|
||||
| Полный портальный аудит | **audit-portal** (project-скил) | — | audit-security | 14-фазный |
|
||||
| PRD → эпик → GitHub-issues → параллельные агенты → код | **CCPM** (vendored skill) | #41 | project-management | `.claude/prds/` + `.claude/epics/` |
|
||||
| PRD / roadmap-update / metrics-review / sprint-planning | **product-management** (Anthropic-плагин) | #42 | project-management | 9 slash-команд |
|
||||
| GitHub-issues операции (просмотр/создание) | **GitHub MCP** | #3 (фаза 0) | — | через `mcp__github__*` |
|
||||
| Извлечь дизайн-токены из Figma | **Figma MCP** | #44 | design-tooling | **DEFERRED** — нет Figma-аккаунта |
|
||||
| Вставить SVG-иконку из 10 коллекций (не Lucide) | **Universal Icons MCP** | #45 | design-tooling | ADR-006 D4: Lucide через `lucide-vue-next` |
|
||||
| Дизайн-критика / UX-копи / a11y-уровня дизайна / research synthesis | **Design plugin** | #46 | design-tooling | pre-code; Pa11y остаётся технический SoT |
|
||||
| Introspection OpenAPI/REST API чужой/своей | **openapi-mcp-server** | #47 | integration-tooling | READ-ONLY |
|
||||
| Генерация OpenAPI-спеки своего API | **api-docs agent** (claude-flow) | — | integration-tooling | без Tooling-номера |
|
||||
| Eval LLM-промпта / red-team / регрессия на промпт | **promptfoo** (npm CLI) | #48 | ml-ai-tooling | вручную/CI, **никогда в хук** (ML1) |
|
||||
| Классический ML-воркфлоу: алгоритм / feature eng / оценка | **Data Scientist skill** | #49 | ml-ai-tooling | knowledge-only |
|
||||
| Исполняемый ML-ноутбук с обучением | **Jupyter MCP** | #50 | ml-ai-tooling | **DEFERRED** — нет Python ML-окружения |
|
||||
| Документировать/оптимизировать/change-management бизнес-процесс | **operations** (9 скилов) | #51 | business-process | Mermaid-рендер делегирует #37 |
|
||||
| BPMN 2.0 to-be модель процесса, RACI, state-машина | **process-modeling** (project-скил) | #52 | business-process | как process-discovery from-head |
|
||||
| As-is discovery процесса из кода Laravel + audit-логов, узкие места, KPI | **process-analysis** (project-скил) | #53 | business-process | from-code; ≠ discovery-interview (from-head) |
|
||||
| Диагностика просадки метрики/конверсии (почему падает B2, где теряем в воронке) | **process-analysis** (project-скил) | #53 | business-process | from-code + audit-данные; discovery-interview SKIP-кейс |
|
||||
| n8n workflow-движок | **n8n-mcp** | #54 | business-process | **DEFERRED** — n8n не в стеке |
|
||||
| Интервью-discovery перед фичей (FEATURE) / ориентация по проекту (SYSTEM) | **discovery-interview** (project-скил) | #55 | discovery-tooling | разрез по слою-источнику с #53 (ADR-009) |
|
||||
| Brainstorm: проблема не очерчена, нужно вскрыть | `superpowers:brainstorming` | — | (Superpowers, §12.2) | не off-phase, но связан |
|
||||
| Создать новый скил из ≥3 повторений workflow | **skill-creator** | #56 | authoring-tooling | политика триггеров ADR-010 |
|
||||
| Создать новый Claude Code plugin | **plugin-dev** | #57 | authoring-tooling | knowledge for plugin authoring |
|
||||
| Создать хук на повторяющуюся ошибку | **hookify** | #58 | authoring-tooling | **HK1 pre-check** на коллизию economy/skill-discipline |
|
||||
| Подсказки настроек Claude Code для проекта | **claude-code-setup** | #59 | dev-support | recommender |
|
||||
| Текущая документация библиотеки/SDK/CLI | **context7** | #60 | dev-support | вместо WebSearch для библиотек |
|
||||
| Аудит денежной корректности биллинга (списание/тариф/баланс/дрейф/charge_source) | **billing-audit** (project-скил) | #62 | finance-tooling | C6; ≠ process-*/D3/ru-tax (ADR-012) |
|
||||
| РСБУ/НК РФ контекст: НДС/УСН, налоговая база, выгрузка бухгалтеру | **ru-tax-accounting** (project-скил) | #63 | finance-tooling | C7; ≠ finance plugin/D1/D2 (ADR-012) |
|
||||
| Сверка счетов / variance-анализ / US-GAAP-отчётность / проводки | **finance plugin** | #61 | finance-tooling | C7; SOX not-applicable, warehouse-MCP DEFERRED (ADR-012) |
|
||||
| Авто-рефакторинг / version-upgrade / удаление мёртвого PHP-кода | **Rector** + rector-laravel | #64 | backend-tooling | manual/CI (`composer rector`/`rector:fix`), не блокирующий — baseline 16 файлов (ADR-013) |
|
||||
| Метрики качества / сложности / архитектуры PHP-кода | **PHP Insights** | #65 | backend-tooling | on-demand/CI (`composer insights`), не блокирующий (BT9, ADR-013) |
|
||||
| Как писать backend в Лидерре (контроллер/сервис/джоб, RLS, деньги, идемпотентность, партиции) | **laravel-backend-patterns** (project-скил) | #66 | backend-tooling | trigger-based; ≠ #38 generic / ≠ #62 audit (ADR-013) |
|
||||
| Коррелированный runtime-трейс request↔job↔query (self-hosted) | **NightOwl** | #67 | backend-tooling | **DEFERRED** — нет pcntl/posix на Windows; pending Б-1 (ADR-013) |
|
||||
| Глубокая «боевая» проверка работающего портала (обход входа, инъекции, XSS) | **OWASP ZAP** (MCP) | #68 | infosec-tooling | DAST; цель по умолч. 127.0.0.1 (IS8); установлен портативно (portable JRE 17, `docs/security/zap-setup.md`); ADR-014 |
|
||||
| Известные уязвимости / открытые двери / слабый TLS снаружи | **Nuclei** (CLI) | #69 | infosec-tooling | `bin/nuclei.exe`, цель **127.0.0.1** (не localhost); CLI не MCP; ADR-014 |
|
||||
| Безопасность настроек Laravel (.env/config/заголовки/cookie/secrets/deps) | **Ward** (CLI) | #70 | infosec-tooling | Go-бинарь `bin/ward.exe` v0.4.1; заменил Enlightn (abandoned/L13); установлен портативно (`docs/security/ward-setup.md`); ADR-014 |
|
||||
| Аудит ПДн / соответствие 152-ФЗ | **pdn-152fz-audit** (project-скил) | #71 | infosec-tooling | 2 режима техника+закон; ≠ pg_anonymizer #29 (IS4) / D2 (IS5) |
|
||||
| Моделирование угроз STRIDE / что защищать перед публикацией | **threat-model** (project-скил) | #72 | infosec-tooling | going-public; ≠ ToB #39 generic (IS6) |
|
||||
| Прогон безопасности перед релизом / go-no-go | **security-go-live** (project-скил) | #73 | infosec-tooling | оркеструет #68-72 + D3; ≠ audit-portal (IS7) |
|
||||
| Маркетинговый контент / SEO-аудит / план кампании / email-цепочка / конкурент-бриф / brand-review / performance-report | **marketing** (Anthropic-плагин, 8 скилов) | #74 | marketing-tooling | первичный решатель C1; оркеструет #77-#81 через RU-контекст (ADR-015) |
|
||||
| Копирайтинг / CRO / lead-magnets / ad-creative / cold-email / marketing-psychology фреймворки | **marketingskills** (вендоренный community-набор, 40 скилов) | #75 | marketing-tooling | материал/резерв-библиотека (модель UPM); ≠ #74 решатель (MKT3, ADR-015) |
|
||||
| Единый тон бренда / голос бренда из текстов | **brand-voice** (Anthropic partner-плагин) | #76 | marketing-tooling | вербальный бренд; ≠ Brandbook v2 визуальный (MKT6, ADR-015) |
|
||||
| РФ-каналы Лидерры / конверсия лендинга / 152-ФЗ для маркетинговых рассылок | **marketing-ru** (project-скил) | #77 | marketing-tooling | RU-специфика; обязателен в L16 (ADR-015) |
|
||||
| Веб-аналитика, источники трафика, гео Яндекс.Метрика | **Яндекс.Метрика MCP** | #78 | marketing-tooling | READ-ONLY; аналитика только (ADR-015) |
|
||||
| Подбор ключевых слов через Wordstat / контекстная реклама | **Яндекс.Директ+Wordstat MCP** | #79 | marketing-tooling | только Wordstat; Direct-мутации отключены (ADR-015) |
|
||||
| Постинг в Telegram-каналы / Telegram-маркетинг | **Telegram MCP** | #80 | marketing-tooling | канал публикации; ≠ #83 email-рассылки (ADR-015) |
|
||||
| Планировщик соцсетей VK + Telegram / очередь публикаций | **Postiz** (self-host) | #81 | marketing-tooling | self-hosted планировщик (ADR-015) |
|
||||
| SEO позиции / поисковая выдача / ключевые слова (платный API) | **DataForSEO** | #82 | marketing-tooling | **DEFERRED** — post-Б-1, платный API (ADR-015) |
|
||||
| Массовая email-рассылка через Unisender Go | **Unisender Go MCP** | #83 | marketing-tooling | **DEFERRED** — своя MCP-обёртка не готова; ≠ #80 Telegram (ADR-015) |
|
||||
| Отладка production runtime errors через self-hosted Sentry | **Sentry MCP** | #34 | debug-runtime | READ-ONLY, pending Б-1 deployment |
|
||||
| Отладка Redis/Memurai очередей / кэша / Pest-квирков 73/77 | **Redis MCP** | #35 | debug-runtime | READ-ONLY обязательно |
|
||||
| Правки `CLAUDE.md` | **claude-md-management** | #33 | infrastructure | §5 п.10 — единственный канал |
|
||||
| UI-резерв (50+ стилей / 161 палитра / 99 UX-гайдлайнов / 25 чартов) | **UI UX Pro Max** | #31 | UI-пул † | PSR_v1 R14.3 pipeline; R6.0+R6.1 фильтр |
|
||||
| UI стартовый шаблон / иконка-логотип бренда | **21st Magic MCP** | #32 | UI-пул † | PSR_v1 R14.4 pipeline; R6.0+R6.1 фильтр |
|
||||
| Оркестрация роя / queen / королева | **ruflo** | — | orchestration | **ИЗОЛИРОВАН 18.05.2026** (Pravila §14.9 dormant) |
|
||||
<!-- auto:routing-table:begin -->
|
||||
<!-- This block is auto-generated from docs/registry/nodes.yaml. Do not edit by hand. -->
|
||||
|
||||
| Классификация | Рекомендуемый узел | Вес |
|
||||
|---|---|---|
|
||||
| `bugfix` | #18 Pest 4 | 1 |
|
||||
| `bugfix` | #19 Superpowers v5.1.0 | 0.8 |
|
||||
| `feature` | #19 Superpowers v5.1.0 | 1 |
|
||||
| `planning` | #19 Superpowers v5.1.0 | 1 |
|
||||
| `refactor` | #19 Superpowers v5.1.0 | 0.8 |
|
||||
|
||||
<!-- auto:routing-table:end -->
|
||||
|
||||
> **† UI-пул (#31 UPM / #32 21st) — делегирующие строки.** R15.6 явно: к UI-пулу R15
|
||||
> не применяется — это UI-задачи по природе, их ведёт R14 pipeline. Строки выше —
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Закрытие 7 дыр аудита журналирования — Master Overview
|
||||
|
||||
> **Триггер:** анализ 23.05.2026 после P0+P1+P2 closure (см. `2026-05-22-audit-*.md` × 3). Заказчик: «делай все 7 и не забудь разместить все на проде».
|
||||
|
||||
**Goal:** закрыть 7 «реальных дыр» из gap-анализа 23.05 и выкатить каждую на боевой liderra.ru.
|
||||
|
||||
**Стратегия деплоя:** по мере готовности — каждая дыра отдельным деплоем (выбор заказчика). Безопаснее: точечный откат при сбое.
|
||||
|
||||
**Решение по объёму #4:** минимум — админ-кнопка + код анонимизации + журнал. Без формы самообслуживания, без email-подтверждения, без 30-дневного SLA (выбор заказчика).
|
||||
|
||||
---
|
||||
|
||||
## Порядок и зависимости
|
||||
|
||||
| № | Дыра | Почему такой порядок | План |
|
||||
|---|---|---|---|
|
||||
| **#7** | Dev↔Prod RLS-разрыв (аудит) | Превентивно — выявит проблемы, которые могут испортить новые фичи 1-6. Паттерн `pgsql_supplier` нужно засеять до их добавления. | `2026-05-23-hole-7-dev-prod-rls-audit.md` |
|
||||
| **#1** | Hash-chain validator | Проверяет целостность того, что уже накапливается. Независим. Должен быть до партиционирования (если найдём повреждение — фиксим прежде чем резать на куски). | `2026-05-23-hole-1-hash-chain-validator.md` (создаётся после #7) |
|
||||
| **#2** | Партиционирование 7 audit-таблиц | Фундамент масштабирования. До добавления новых watcher-job'ов в #3+#5. | `2026-05-23-hole-2-audit-partitioning.md` |
|
||||
| **#3+#5** | Watcher coverage (доп. пороги + другие job-классы) | Объединены — оба правят `IncidentsWatchFailures` и/или его таблицу `incidents_log`. | `2026-05-23-hole-3-5-watcher-coverage.md` |
|
||||
| **#6** | Scheduler heartbeat | Простой — pulse-таблица + watcher. Независим. | `2026-05-23-hole-6-scheduler-heartbeat.md` |
|
||||
| **#4** | 152-ФЗ minimum | Самостоятельная фича, наименее связанная с журналированием — последней. | `2026-05-23-hole-4-152fz-erasure-minimum.md` |
|
||||
|
||||
---
|
||||
|
||||
## Tracker
|
||||
|
||||
- [x] **#7 RLS-аудит** ✅ DONE+прод+smoke (push `fb4e711b`, 23.05 утром)
|
||||
- [x] **#1 hash-chain validator** ✅ DONE+прод+smoke (push `a195611d`, 23.05 утром; per-RLS-scope находка)
|
||||
- [ ] **#2 partitioning** ⏸ сознательно отложено — большая миграция боевой БД, отдельная сессия (заказчик 23.05 вечером)
|
||||
- [x] **#3+#5 watcher coverage** ✅ DONE+прод+smoke (push `527f628a`, 23.05 вечером; +failed_jobs + 3 правила: spike/daily-total/persistent)
|
||||
- [x] **#6 heartbeat** ✅ DONE+прод+smoke (push `c76038d0`+hotfix `33462bf5`, 23.05 вечером; schema v8.30, 12 baseline rows)
|
||||
- [x] **#4 152-ФЗ minimum** ✅ DONE+прод+smoke (push `77e98afa`+Eloquent fix `f5482f4`, 23.05 вечером; backend + frontend build deploy)
|
||||
- [x] **Финал:** ПИЛОТ.md / memory sync ✅ — этот документ (UI-приёмка #4 в админке — за заказчиком)
|
||||
|
||||
**Итог:** 6 из 7 дыр закрыты на боевой liderra.ru за 23.05.2026. #2 — единственная оставшаяся, отдельная сессия (миграция БД).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (сознательно отложено или не делаем)
|
||||
|
||||
- **Backfill 412 строк activity_log** с NULL-автором (решение B=a из P1)
|
||||
- **Retroactive pd_processing_log** для 417 существующих сделок (решение P0)
|
||||
- **Lockbox / DDoS / Sentry / strict CSP / off-site backup** — все ждут Б-1 (регистрация ООО)
|
||||
- **Полная фича 152-ФЗ** (форма самообслуживания, email-подтверждение, 30-дневный SLA) — выбор заказчика «минимум»
|
||||
|
||||
---
|
||||
|
||||
## Регламентные напоминания
|
||||
|
||||
- §5 п.10 — правки CLAUDE.md только через claude-md-management (или прямой Edit при worktree-эксцепшне)
|
||||
- §15.1 — субагенты Sonnet/Opus для git-задач (не Haiku)
|
||||
- §15.2 — pre-flight `git fetch && git log HEAD..origin/main --oneline` перед нормативкой
|
||||
- §5.2 — никаких ПДн/токенов/секретов в коммитах (gitleaks pre-commit enforces)
|
||||
- НЕ skip lefthook хуков (`--no-verify`) без явной авторизации заказчика
|
||||
- Деплой по уже отработанному паттерну — scp tar-pipe → `sudo install -D -m 644 -o www-data -g www-data` → `php artisan config:clear && supervisor restart`
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
# Дыра #1: Валидатор хеш-цепочки audit-журналов — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development` / `executing-plans`. TDD, checkbox steps.
|
||||
|
||||
**Goal:** периодически проверять целостность SHA-256 hash-chain во всех audit-таблицах. Если цепочка порвана (кто-то изменил/удалил строку в обход триггеров — напр. прямым SQL под суперюзером) — поднять инцидент + email-алёрт. Сейчас hash пишется, но НИКТО его не проверяет → tampering незаметен.
|
||||
|
||||
**Architecture:** artisan-команда `audit:verify-chains` (cron daily) идёт по каждой из 6 hash-chain таблиц, пересчитывает цепочку и сравнивает с хранимым `log_hash`. Разрыв → `incidents_log` (severity high, через `pgsql_supplier` BYPASSRLS — паттерн дыры #7) + email на `kdv1@bk.ru`. Read-only к audit-таблицам (их UPDATE/DELETE и так запрещён `audit_block_mutation`).
|
||||
|
||||
**Tech Stack:** Laravel 13, PostgreSQL 16, Pest 4.
|
||||
|
||||
## Hash-chain механизм (из db/schema.sql §14, функция `audit_chain_hash()`)
|
||||
|
||||
- BEFORE INSERT триггер: `NEW.log_hash := digest(COALESCE(prev_hash,''::bytea) || NEW::text::bytea, 'sha256')`.
|
||||
- `prev_hash` = `log_hash` последней строки (`ORDER BY id DESC LIMIT 1`).
|
||||
- В момент вычисления `NEW.log_hash` ещё **NULL** → сериализуется `NEW::text` со столбцом `log_hash` пустым (NULL).
|
||||
- **6 таблиц:** `auth_log`, `activity_log`, `pd_processing_log`, `saas_admin_audit_log`, `balance_transactions`, `tenant_operations_log`.
|
||||
|
||||
## КРИТИЧНО — точность сериализации
|
||||
|
||||
Валидатор ОБЯЗАН воспроизвести ровно `sha256(prev_log_hash || <row с log_hash=NULL>::text)`. Это лучше делать в PostgreSQL (та же сериализация `ROW::text`, что у триггера), НЕ в PHP (форматы дат/типов разойдутся). Подход: SQL-запрос, который по каждой таблице берёт строки `ORDER BY id`, через `lag(log_hash) OVER (ORDER BY id)` берёт prev, и пересчитывает `digest(coalesce(prev,''::bytea) || <row-with-null-log_hash>::text::bytea, 'sha256')`, сравнивает со stored `log_hash`.
|
||||
|
||||
Чтобы получить `<row с log_hash=NULL>::text`: построить явный `ROW(col1, col2, ..., NULL /*log_hash position*/, ...)::text` со списком колонок таблицы в порядке схемы (log_hash на своём месте = NULL). Список колонок каждой таблицы взять из `information_schema.columns ORDER BY ordinal_position` или из db/schema.sql.
|
||||
|
||||
**TDD-якорь:** первый тест — валидатор на НЕТРОНУТОЙ цепочке (после нескольких INSERT через нормальный путь) ДОЛЖЕН вернуть «целостно». Если он флагует разрыв на честной цепочке — сериализация не совпала, чините SQL до зелёного. Только потом тест на tampering.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: VerifyAuditChains command skeleton + per-table column maps
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Console/Commands/VerifyAuditChains.php`
|
||||
- Create test: `app/tests/Feature/Console/VerifyAuditChainsTest.php`
|
||||
|
||||
- [ ] **Step 1.1: Write failing test — clean chain passes**
|
||||
|
||||
```php
|
||||
<?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);
|
||||
|
||||
test('clean auth_log chain verifies intact', function () {
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
// insert a few auth_log rows via normal path (trigger fills log_hash)
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
DB::table('auth_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event' => 'login',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
// no incident created for intact chain
|
||||
expect(DB::connection('pgsql_supplier')->table('incidents_log')
|
||||
->where('summary', 'like', '%chain%')->count())->toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
(adjust auth_log columns to actual schema — read db/schema.sql.)
|
||||
|
||||
- [ ] **Step 1.2: Run — FAIL (command missing).**
|
||||
- [ ] **Step 1.3: Implement command.** `protected $signature = 'audit:verify-chains'`. `private const DB_CONNECTION = 'pgsql_supplier';`. Const array of 6 tables. For each table: run the recompute SQL (above), collect rows where `stored IS DISTINCT FROM expected`. Use `DB::connection(self::DB_CONNECTION)`.
|
||||
- [ ] **Step 1.4: Run — PASS (clean chain, 0 mismatches).**
|
||||
- [ ] **Step 1.5: Commit.**
|
||||
|
||||
## Task 2: Tampering detection + incident + alert
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Console/Commands/VerifyAuditChains.php`
|
||||
- Modify test.
|
||||
|
||||
- [ ] **Step 2.1: Failing test — tampered chain detected.**
|
||||
To simulate tampering despite `audit_block_mutation` (forbids UPDATE/DELETE): in the test, temporarily disable the triggers, mutate a row, re-enable:
|
||||
|
||||
```php
|
||||
test('tampered chain raises incident', function () {
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
DB::table('auth_log')->insert([...]);
|
||||
}
|
||||
// tamper: disable triggers, change a middle row's data, re-enable
|
||||
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
|
||||
DB::table('auth_log')->orderBy('id')->limit(1)->update(['ip_address' => '6.6.6.6']);
|
||||
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
|
||||
|
||||
$this->artisan('audit:verify-chains')->assertFailed(); // non-zero exit on breach
|
||||
expect(DB::connection('pgsql_supplier')->table('incidents_log')
|
||||
->where('type','other')->where('severity','high')
|
||||
->where('summary','like','%auth_log%')->count())->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Run — FAIL.**
|
||||
- [ ] **Step 2.3: Implement.** On any mismatch: insert `incidents_log` row (via pgsql_supplier; `type='other'`, `severity='high'`, `summary` naming table + first broken id), dedup (don't duplicate same-table incident within 24h), send email to `kdv1@bk.ru` (use existing Mail pattern — search codebase for an existing Mailable or `Mail::raw`), and command exits non-zero (`return self::FAILURE`).
|
||||
- [ ] **Step 2.4: Run — PASS.**
|
||||
- [ ] **Step 2.5: Commit.**
|
||||
|
||||
## Task 3: Schedule + full regression
|
||||
|
||||
- [ ] **Step 3.1:** Add to `app/routes/console.php`: `Schedule::command('audit:verify-chains')->dailyAt('04:00');` (mirror existing schedule style).
|
||||
- [ ] **Step 3.2:** `cd app && ./vendor/bin/pest tests/Feature/Console/VerifyAuditChainsTest.php` — PASS.
|
||||
- [ ] **Step 3.3:** `cd app && ./vendor/bin/pest --parallel` — green baseline.
|
||||
- [ ] **Step 3.4: Commit.**
|
||||
|
||||
## Deploy (controller, after merge)
|
||||
|
||||
- scp `VerifyAuditChains.php` + `routes/console.php` → prod, sed CRLF, install www-data.
|
||||
- restart liderra-queue + reload php-fpm.
|
||||
- Smoke: `sudo -u www-data php artisan audit:verify-chains` → expect "all chains intact" exit 0 on real prod data (verifies serialization matches on prod too).
|
||||
|
||||
## Self-review (controller)
|
||||
|
||||
1. Clean chain on DEV passes (serialization correct)?
|
||||
2. Tampered detection works?
|
||||
3. Uses pgsql_supplier (works on prod non-bypass role)?
|
||||
4. Dedup prevents incident spam?
|
||||
5. Prod smoke: real chains verify intact (no false positive)?
|
||||
@@ -0,0 +1,199 @@
|
||||
# Дыра #2 — план реализации partitioning 7 audit-таблиц
|
||||
|
||||
**Spec:** `2026-05-23-hole-2-audit-partitioning-design.md` v1.0
|
||||
**Решения заказчика (23.05 вечер):**
|
||||
|
||||
- Scope: все 7 таблиц
|
||||
- FK: W1 (удалить FK на webhook_log от failed_webhook_jobs/rejected_deals_log)
|
||||
- Retention: предложенные defaults (auth:24м, activity:36м, tenant_ops:24м, webhook:3м, balance:84м, pd:36м, saas_admin:84м) + cron Sundays 03:00 МСК
|
||||
- Hash-chain: per-partition (совместимо с hole #1 fix)
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Разработка (dev)
|
||||
|
||||
### A.1. Расширить whitelist
|
||||
|
||||
**Файл:** `app/app/Services/MonthlyPartitionManager.php`
|
||||
**Изменение:** добавить 7 строк в `PARTITIONED_TABLES`. Map имя→partition-key:
|
||||
|
||||
```php
|
||||
public const PARTITIONED_TABLES = [
|
||||
'deals' => 'received_at',
|
||||
'supplier_lead_costs' => 'received_at',
|
||||
'auth_log' => 'created_at',
|
||||
'activity_log' => 'created_at',
|
||||
'tenant_operations_log' => 'created_at',
|
||||
'webhook_log' => 'received_at', // в этой таблице ключ называется received_at
|
||||
'balance_transactions' => 'created_at',
|
||||
'pd_processing_log' => 'created_at',
|
||||
'saas_admin_audit_log' => 'created_at',
|
||||
];
|
||||
```
|
||||
|
||||
Refactor `ensureMonth()` чтобы использовал key из map (сейчас hardcoded на `received_at`).
|
||||
|
||||
**Test:** `MonthlyPartitionManagerTest` — добавить positive case на каждую новую таблицу + проверить что old test (throw на 'orders') продолжает работать.
|
||||
|
||||
### A.2. Миграция rewrite
|
||||
|
||||
**Файл:** `app/database/migrations/2026_05_23_000002_partition_audit_tables.php`
|
||||
**Логика для каждой таблицы** (в одной транзакции BEGIN/COMMIT):
|
||||
|
||||
1. `ALTER TABLE auth_log RENAME TO auth_log_old`
|
||||
2. `CREATE TABLE auth_log (...same columns...) PARTITION BY RANGE (created_at)` с PK=`(id, created_at)`
|
||||
3. Создать 6 партиций (3 прошлых + текущий + 2 будущих) — диапазоны `[Y_m_01, Y_m+1_01)`
|
||||
4. Восстановить все индексы (как локальные через `CREATE INDEX ... ON auth_log (...)` — автонаследуются партиции)
|
||||
5. Восстановить RLS-политики
|
||||
6. Восстановить триггеры (особенно `audit_chain_hash` через `BEFORE INSERT`)
|
||||
7. `INSERT INTO auth_log SELECT * FROM auth_log_old`
|
||||
8. `DROP TABLE auth_log_old CASCADE`
|
||||
|
||||
**FK удаление (только для webhook_log):**
|
||||
|
||||
- `ALTER TABLE failed_webhook_jobs DROP CONSTRAINT failed_webhook_jobs_webhook_log_id_fkey`
|
||||
- `ALTER TABLE rejected_deals_log DROP CONSTRAINT rejected_deals_log_webhook_log_id_fkey`
|
||||
- Имена FK выяснить через `\d` или `pg_constraint`
|
||||
|
||||
**ВАЖНО:** миграция через `DB::unprepared()` (raw SQL) — Laravel migrate уже паттерн на проде через postgres-role (см. hole #6 deploy lessons).
|
||||
|
||||
### A.3. Обновить `db/schema.sql`
|
||||
|
||||
- Заменить 7 объявлений `CREATE TABLE` на партиционированные версии
|
||||
- PK `(id)` → `(id, created_at)` (или `received_at`)
|
||||
- Bump schema header: v8.30 → v8.31
|
||||
- Запись в `db/CHANGELOG_schema.md`
|
||||
|
||||
### A.4. Adapt `VerifyAuditChains` (per-partition scan)
|
||||
|
||||
**Файл:** `app/app/Console/Commands/VerifyAuditChains.php`
|
||||
**Изменение:** для каждой из 6 hash-chain таблиц (`auth_log`, `activity_log`, `tenant_operations_log`, `balance_transactions`, `pd_processing_log`, `saas_admin_audit_log`):
|
||||
|
||||
- Получить список партиций через `pg_inherits` JOIN `pg_class`
|
||||
- Для каждой партиции — проверить hash-chain отдельно
|
||||
- Несоответствие в любой партиции → инцидент с указанием partition_name в summary
|
||||
|
||||
**Test:** обновить `VerifyAuditChainsTest` — добавить кейс «разрыв в одной партиции, остальные intact → 1 инцидент с partition_name».
|
||||
|
||||
### A.5. Новая команда `partitions:drop-expired`
|
||||
|
||||
**Файл:** `app/app/Console/Commands/PartitionsDropExpired.php`
|
||||
**Логика:**
|
||||
|
||||
- Получить retention настройки из `system_settings` (defaults в коде, override через DB):
|
||||
- `auth_log_retention_months`, `activity_log_retention_months`, etc. (7 ключей)
|
||||
- Для каждой таблицы из `PARTITIONED_TABLES`:
|
||||
- Найти партиции старше `NOW() - INTERVAL '{retention} months'`
|
||||
- `DROP TABLE IF EXISTS {partition_name}` (атомарно)
|
||||
- Лог: сколько дропнуто, какой freed space
|
||||
- Если retention=0 (или не задан) — НЕ дропать (защита от случайного `0`)
|
||||
|
||||
**Cron в `routes/console.php`:** Sundays 03:00 МСК + heartbeat-обёртка (как hole #6 паттерн).
|
||||
|
||||
**Mailable:** опционально — `PartitionsDroppedReport` ежемесячно? Решим в коде — для МИНИМУМА просто info-лог.
|
||||
|
||||
**Test:** `PartitionsDropExpiredTest`:
|
||||
|
||||
- настройка retention=2 → дропает партиции старше 2 мес
|
||||
- защита от 0/null → no-op
|
||||
- идемпотентность (повторный run — 0 дропов)
|
||||
|
||||
### A.6. seed retention defaults в system_settings
|
||||
|
||||
**Файл:** `db/schema.sql` (раздел `system_settings` seed) + миграция inserts:
|
||||
|
||||
```sql
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('auth_log_retention_months', '24', 'Retention auth_log в месяцах (hole #2)'),
|
||||
...7 строк...
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
```
|
||||
|
||||
### A.7. Регрессия
|
||||
|
||||
- `php artisan test` — все green (особенно: Auth*, Pd*, BalanceTransactions*, ImpersonationAudit*, PartitionsCreateMonths*, VerifyAuditChains*)
|
||||
- `pint --test`
|
||||
- `cspell` + `markdownlint`
|
||||
|
||||
### A.8. Локальный smoke
|
||||
|
||||
- `php artisan migrate:fresh --env=testing` — применит миграцию, схема корректна
|
||||
- `php artisan partitions:create-months` — создаст партиции для всех 9 таблиц (2 старые + 7 новые)
|
||||
- `php artisan partitions:drop-expired` — no-op (нет старых партиций)
|
||||
- `php artisan audit:verify-chains` — должен пройти per-partition (empty chains = OK)
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Прод-выкатка (с явным approve каждого критического шага)
|
||||
|
||||
### B.1. Pre-flight
|
||||
|
||||
- `git fetch && git log HEAD..origin/main --oneline` — pre-flight sync
|
||||
- Push всех A-коммитов на origin/main
|
||||
|
||||
### B.2. Backup БД
|
||||
|
||||
```bash
|
||||
ssh ubuntu@111.88.246.137
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
sudo -u postgres pg_dump -Fc liderra > /home/ubuntu/deploy-backups/pre-partitioning-$TS.dump
|
||||
# Verify
|
||||
ls -lh /home/ubuntu/deploy-backups/pre-partitioning-$TS.dump
|
||||
sudo -u postgres pg_restore --list /home/ubuntu/deploy-backups/pre-partitioning-$TS.dump | wc -l
|
||||
```
|
||||
|
||||
### B.3. Pre-migration snapshot
|
||||
|
||||
```sql
|
||||
SELECT 'auth_log', COUNT(*) FROM auth_log
|
||||
UNION ALL SELECT 'activity_log', COUNT(*) FROM activity_log
|
||||
... 7 таблиц ...;
|
||||
```
|
||||
|
||||
### B.4. Deploy code
|
||||
|
||||
scp нескольких файлов: `MonthlyPartitionManager.php`, `VerifyAuditChains.php`, `PartitionsDropExpired.php`, `routes/console.php`. Install, optimize.
|
||||
|
||||
### B.5. Apply migration
|
||||
|
||||
Через `sudo -u postgres psql -d liderra -f /tmp/migration.sql` (НЕ через artisan migrate — права crm_app_user не хватит на DDL, повторяет hole #6 lesson). После — INSERT в `migrations` для учёта Laravel.
|
||||
|
||||
### B.6. Post-migration verify
|
||||
|
||||
- row counts соответствуют B.3 snapshot
|
||||
- `\d+ auth_log` показывает PARTITION BY и 6 партиций
|
||||
- `audit:verify-chains` rc=0 без инцидентов
|
||||
|
||||
### B.7. Smoke 1 час
|
||||
|
||||
Watch incidents_log, scheduler_heartbeats, queue activity.
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Documentation
|
||||
|
||||
- ПИЛОТ.md §6 п.11 — добавить под-пункт #2
|
||||
- ЭТАЛОН.md — schema v8.31, metrics обновить
|
||||
- `memory/project_7holes_audit_followup` — закрыть #2
|
||||
- `docs/superpowers/plans/2026-05-23-7-holes-overview.md` — tracker обновить (все 7 ✅)
|
||||
|
||||
---
|
||||
|
||||
## Tracker
|
||||
|
||||
- [ ] A.1. Whitelist + map в `MonthlyPartitionManager`
|
||||
- [ ] A.2. Миграция rewrite (7 таблиц + FK drop)
|
||||
- [ ] A.3. Update `db/schema.sql` + CHANGELOG
|
||||
- [ ] A.4. Adapt `VerifyAuditChains` per-partition
|
||||
- [ ] A.5. `PartitionsDropExpired` команда + cron
|
||||
- [ ] A.6. Seed retention defaults
|
||||
- [ ] A.7. Regression
|
||||
- [ ] A.8. Локальный smoke
|
||||
- [ ] B.1. Pre-flight
|
||||
- [ ] B.2. Backup
|
||||
- [ ] B.3. Snapshot
|
||||
- [ ] B.4. Deploy code
|
||||
- [ ] B.5. Apply migration
|
||||
- [ ] B.6. Verify
|
||||
- [ ] B.7. Smoke 1h
|
||||
- [ ] C.1-C.4. Docs
|
||||
@@ -0,0 +1,292 @@
|
||||
# Дыра #7: Аудит dev↔prod RLS-разрыва — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** найти все Console-команды и Job-классы Лидерры, которые читают/пишут таблицы с RLS-политиками на `app.current_tenant_id` (или другие session vars), но не используют BYPASSRLS-канал (`pgsql_supplier`) и не устанавливают session-переменную. Пофиксить каждое найденное место. Класс проблемы: «работает на dev (`postgres` superuser, implicit BYPASSRLS), падает на prod».
|
||||
|
||||
**Прецедент:** 22.05 `IncidentsWatchFailures` упал на проде с `unrecognized configuration parameter "app.current_tenant_id"` — хотфикс через `DB::connection('pgsql_supplier')`. Сегодня — поиск всех остальных мест с тем же риском.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- **Phase A (discovery, read-only):** статический анализ — grep по `app/app/Console/Commands/**/*.php` + `app/app/Jobs/**/*.php` + `app/routes/console.php` × список RLS-таблиц из `db/schema.sql`. Выход — отчёт `docs/audit/2026-05-23-rls-gap-audit.md` с матрицей.
|
||||
- **Phase B (fix-as-found):** для каждого ❌-findings — отдельный коммит «switch to pgsql_supplier» с обновлением тестов через `SharesSupplierPdo` trait. Pattern взят из `IncidentsWatchFailures.php` (commit `5df34a61` после P2-hotfix).
|
||||
- **Phase C (deploy):** scp-tar-pipe изменённых файлов на боевой liderra.ru + restart workers.
|
||||
|
||||
**Tech Stack:** Laravel 13, PostgreSQL 16, Pest 4, PowerShell/Bash через SSH.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Discovery (read-only)
|
||||
|
||||
### Task A1: Inventory RLS-protected tables
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read: `db/schema.sql` (find all `CREATE POLICY` referencing `current_setting('app.current_tenant_id'`, `current_setting('app.current_admin_id'`, or similar)
|
||||
- Create: `docs/audit/2026-05-23-rls-gap-audit.md`
|
||||
|
||||
- [ ] **Step A1.1: Grep schema for RLS policies on app.* session vars**
|
||||
|
||||
Command:
|
||||
|
||||
```
|
||||
grep -n "current_setting('app\." db/schema.sql
|
||||
```
|
||||
|
||||
Expected: ~30-50 hits across 15-25 distinct tables.
|
||||
|
||||
- [ ] **Step A1.2: Compile table list (with policy summary)**
|
||||
|
||||
Output a list like:
|
||||
|
||||
```
|
||||
- auth_log — tenant_isolation on app.current_tenant_id
|
||||
- activity_log — tenant_isolation on app.current_tenant_id
|
||||
- pd_processing_log — tenant_isolation on app.current_tenant_id
|
||||
- webhook_log — tenant_isolation on app.current_tenant_id
|
||||
- incidents_log — tenant_isolation on app.current_tenant_id
|
||||
- tenant_operations_log — tenant_isolation on app.current_tenant_id
|
||||
- saas_admin_audit_log — admin_isolation on app.current_admin_id
|
||||
- ... (continue)
|
||||
```
|
||||
|
||||
Write to section "## Inventory" of the report file.
|
||||
|
||||
### Task A2: Inventory cron/job classes
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read: `app/app/Console/Commands/**/*.php`, `app/app/Jobs/**/*.php`, `app/routes/console.php`
|
||||
|
||||
- [ ] **Step A2.1: List Console commands**
|
||||
|
||||
Glob: `app/app/Console/Commands/**/*.php`
|
||||
|
||||
Expected ~20-40 files. Record class name, file path, and short purpose (from class docblock or `$description`).
|
||||
|
||||
- [ ] **Step A2.2: List Job classes**
|
||||
|
||||
Glob: `app/app/Jobs/**/*.php`
|
||||
|
||||
Expected ~15-30 files.
|
||||
|
||||
- [ ] **Step A2.3: Cross-check with scheduler**
|
||||
|
||||
Read `app/routes/console.php` — note which commands are scheduled (cron context, no tenant in session) vs. ad-hoc (artisan called inside HTTP request, may have tenant set).
|
||||
|
||||
Write to section "## Inventory: commands and jobs".
|
||||
|
||||
### Task A3: Static analysis — for each command/job, check RLS-table touches
|
||||
|
||||
For each file from A2:
|
||||
|
||||
- Grep for `DB::table('<rls_table>')`, `DB::connection(...)->table('<rls_table>')`, and any Model class whose table is RLS-protected (Models in `app/app/Models/` → match $table to A1 list).
|
||||
- For each touch, classify:
|
||||
- ✅ **SAFE** — uses `DB::connection('pgsql_supplier')` OR explicitly calls `DB::statement('SET LOCAL app.current_tenant_id = ...')` first
|
||||
- ❌ **GAP** — touches RLS-table on default connection without setting session var
|
||||
- ⚠️ **AMBIGUOUS** — touch happens inside `tenants()->each(function ($t) { DB::statement('SET LOCAL ...'); ... })` loop — needs case-by-case review
|
||||
- ➖ **N/A** — file doesn't touch any A1 table
|
||||
|
||||
- [ ] **Step A3.1: Run static analysis**
|
||||
|
||||
For each command/job:
|
||||
|
||||
1. Read the file.
|
||||
2. Grep for table names from A1 list.
|
||||
3. Classify per above rules.
|
||||
|
||||
Output matrix in section "## Findings" of the report:
|
||||
|
||||
```
|
||||
| File | Tables touched | Classification | Reason |
|
||||
|------|---------------|----------------|--------|
|
||||
| app/app/Console/Commands/Foo.php | activity_log | ❌ GAP | DB::table('activity_log') without SET LOCAL or pgsql_supplier |
|
||||
| app/app/Jobs/BarJob.php | webhook_log, incidents_log | ✅ SAFE | uses DB::connection('pgsql_supplier') |
|
||||
| ... |
|
||||
```
|
||||
|
||||
- [ ] **Step A3.2: Tally and prioritize**
|
||||
|
||||
Summary table at top of "## Findings":
|
||||
|
||||
- Total commands+jobs analyzed: NN
|
||||
- ✅ SAFE: NN
|
||||
- ❌ GAP: NN (list each — these need fixing)
|
||||
- ⚠️ AMBIGUOUS: NN (list each — needs human review)
|
||||
- ➖ N/A: NN
|
||||
|
||||
### Task A4: Commit the audit report
|
||||
|
||||
- [ ] **Step A4.1: Commit**
|
||||
|
||||
```
|
||||
git add docs/audit/2026-05-23-rls-gap-audit.md
|
||||
git commit -- docs/audit/2026-05-23-rls-gap-audit.md -m "$(cat <<'EOF'
|
||||
docs(audit): RLS dev↔prod gap discovery — Phase A of hole #7
|
||||
|
||||
Inventory + static analysis of all Console commands and Job classes
|
||||
against tables with RLS policies on app.current_tenant_id / app.current_admin_id.
|
||||
|
||||
Prompted by 22.05 IncidentsWatchFailures prod-only failure (RLS context
|
||||
absent in cron). This audit finds all similar latent gaps.
|
||||
|
||||
Phase B (fixes) follows in separate commits per finding.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
**STOP after Phase A** — return the report path to the controller. Controller reviews ❌ findings and approves Phase B execution.
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Fix-as-found (per ❌ finding)
|
||||
|
||||
For each ❌ GAP finding in the report, repeat:
|
||||
|
||||
### Task B-N: Fix [filename]
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Console/Commands/<File>.php` OR `app/app/Jobs/<File>.php`
|
||||
- Modify: `app/tests/Feature/Console/<File>Test.php` OR equivalent test file
|
||||
|
||||
- [ ] **Step B-N.1: Add SharesSupplierPdo to test file (if missing)**
|
||||
|
||||
Pattern from `IncidentsWatchFailuresTest.php`:
|
||||
|
||||
```php
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
```
|
||||
|
||||
- [ ] **Step B-N.2: Switch RLS-table queries to pgsql_supplier**
|
||||
|
||||
Pattern from `IncidentsWatchFailures.php` (commit `5df34a61`):
|
||||
|
||||
Add:
|
||||
|
||||
```php
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
```
|
||||
|
||||
Replace each `DB::table('<rls_table>')` with `DB::connection(self::DB_CONNECTION)->table('<rls_table>')`.
|
||||
|
||||
Keep non-RLS table queries on default connection.
|
||||
|
||||
- [ ] **Step B-N.3: Run test for this file**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest tests/Feature/Console/<File>Test.php
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step B-N.4: Run full suite to ensure no regression**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --parallel
|
||||
```
|
||||
|
||||
Expected: same green baseline as before (~742/739/3sk/0).
|
||||
|
||||
- [ ] **Step B-N.5: Commit**
|
||||
|
||||
```
|
||||
git add app/app/Console/Commands/<File>.php app/tests/Feature/Console/<File>Test.php
|
||||
git commit -- app/app/Console/Commands/<File>.php app/tests/Feature/Console/<File>Test.php -m "$(cat <<'EOF'
|
||||
fix(rls): <File> — switch to pgsql_supplier (dev↔prod RLS gap)
|
||||
|
||||
Found by hole #7 audit (docs/audit/2026-05-23-rls-gap-audit.md).
|
||||
<File> touched RLS-table <table> via default connection without
|
||||
setting app.current_tenant_id — would fail on prod where the role
|
||||
is not BYPASSRLS.
|
||||
|
||||
Mirror of P2 IncidentsWatchFailures hotfix pattern (commit 5df34a61).
|
||||
|
||||
Tests: <File>Test now uses SharesSupplierPdo trait for cross-connection
|
||||
visibility in DatabaseTransactions wrapper.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Push and Deploy
|
||||
|
||||
### Task C1: Push all fix commits
|
||||
|
||||
- [ ] **Step C1.1: Final pre-flight**
|
||||
|
||||
```
|
||||
git fetch origin
|
||||
git log HEAD..origin/main --oneline
|
||||
```
|
||||
|
||||
If non-empty: rebase or stop and ask controller.
|
||||
|
||||
- [ ] **Step C1.2: Push**
|
||||
|
||||
```
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Task C2: Deploy to prod (per affected file)
|
||||
|
||||
For each fixed file, follow the established pattern from P2 deploy:
|
||||
|
||||
- [ ] **Step C2.1: Compute local MD5**
|
||||
|
||||
```powershell
|
||||
Get-FileHash -Algorithm MD5 app/app/Console/Commands/<File>.php
|
||||
```
|
||||
|
||||
- [ ] **Step C2.2: SCP to server**
|
||||
|
||||
```bash
|
||||
scp -i ~/.ssh/liderra_deploy app/app/Console/Commands/<File>.php ubuntu@111.88.246.137:/tmp/<File>.php
|
||||
```
|
||||
|
||||
- [ ] **Step C2.3: Install on server**
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/liderra_deploy ubuntu@111.88.246.137 "sudo install -D -m 644 -o www-data -g www-data /tmp/<File>.php /var/www/liderra/app/app/Console/Commands/<File>.php && sed -i 's/\r$//' /var/www/liderra/app/app/Console/Commands/<File>.php"
|
||||
```
|
||||
|
||||
- [ ] **Step C2.4: Verify MD5 match**
|
||||
|
||||
```bash
|
||||
ssh ... "md5sum /var/www/liderra/app/app/Console/Commands/<File>.php"
|
||||
```
|
||||
|
||||
- [ ] **Step C2.5: Clear config cache + restart workers (once after all files)**
|
||||
|
||||
```bash
|
||||
ssh ... "cd /var/www/liderra/app && php artisan config:clear && sudo systemctl restart liderra-worker"
|
||||
```
|
||||
|
||||
- [ ] **Step C2.6: Smoke test**
|
||||
|
||||
For each affected scheduled command, manually invoke once:
|
||||
|
||||
```bash
|
||||
ssh ... "cd /var/www/liderra/app && php artisan <command:name>"
|
||||
```
|
||||
|
||||
Expected: exit 0, no `unrecognized configuration parameter` errors.
|
||||
|
||||
### Task C3: Update tracker
|
||||
|
||||
- [ ] Mark hole #7 as done in `docs/superpowers/plans/2026-05-23-7-holes-overview.md` Tracker section.
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist (for controller after subagent returns)
|
||||
|
||||
1. **Phase A report quality:** does the matrix cover all commands and jobs? Any obvious omissions?
|
||||
2. **Classification accuracy:** spot-check 3-5 ✅ SAFE and 3-5 ❌ GAP findings — does the reasoning hold?
|
||||
3. **AMBIGUOUS handling:** review each ⚠️ case manually — decide GAP or SAFE.
|
||||
4. **Phase B coverage:** every ❌ GAP from Phase A has a corresponding fix commit?
|
||||
5. **No regressions:** Pest full suite still green?
|
||||
6. **Deploy verification:** MD5 match on every deployed file, smoke tests pass.
|
||||
@@ -0,0 +1,954 @@
|
||||
# Observer parser — skill/hook expand (schema v3) 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:** Расширить observer-парсер двумя полями для дисциплинарного анализа: имена хук-скриптов (reverse-lookup `.claude/settings.json`) и `recommended_node` для direct-эпизодов (из classification-map). Forward-only schema v2 → v3.
|
||||
|
||||
**Architecture:** Два новых pure-модуля (`observer-hook-resolver.mjs`, `observer-recommended-node.mjs`) + ~15 LoC delta в `observer-transcript-parser.mjs` + минимальная правка `brain-retro-analyzer.mjs` (фильтр `>= 2`, +1 factor-ось) + `missed-activations.mjs` (фильтр `< 2`) + новая секция в `brain-retro` aggregation-template.
|
||||
|
||||
**Tech Stack:** Node.js ES modules (`.mjs`), pure (no exec, no fs side-effects per Security Guidance #40), vitest для тестов через `npm run test:tools` (config `app/vitest.config.tools.mjs`), Node `node:crypto` для SHA-fallback.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md](../specs/2026-05-23-observer-parser-skill-hook-expand-design.md)
|
||||
|
||||
---
|
||||
|
||||
## Pre-flight (обязательно перед стартом, Pravila §15.2)
|
||||
|
||||
- [ ] **Pre-flight sync**
|
||||
|
||||
```bash
|
||||
git fetch && git log HEAD..origin/main --oneline
|
||||
```
|
||||
|
||||
Expected: пусто, либо ясный понятный список коммитов параллельной сессии. Если в списке есть `docs/observer/`, `tools/observer-*`, `tools/brain-retro-*`, `tools/missed-activations*`, `.claude/skills/brain-retro/` — **СТОП**, мерджить/ребейзить сначала.
|
||||
|
||||
- [ ] **Branch + worktree note**
|
||||
|
||||
Текущая ветка проверяется заказчиком. План рассчитан на ту же ветку, в которой коммитнут spec (`feat/supplier-group-sync-fix` или последующая). Каждый Task = один atomic commit, не push'им внутри плана.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create:** `tools/observer-hook-resolver.mjs` (~80 LoC) — pure resolver matcher → script names.
|
||||
- **Create:** `tools/observer-hook-resolver.test.mjs` — 8 vitest cases.
|
||||
- **Create:** `tools/observer-recommended-node.mjs` (~30 LoC) — pure: classification → first live node ID.
|
||||
- **Create:** `tools/observer-recommended-node.test.mjs` — 5 vitest cases.
|
||||
- **Modify:** `tools/observer-transcript-parser.mjs` — ~15 LoC delta (import + extractProcessEvents расширение + parseTranscript primary_rationale `recommended_node` + bump `schema_version: 2 → 3`).
|
||||
- **Modify:** `tools/observer-transcript-parser.test.mjs` — +3 case (hook scripts, direct recommended, skill no-recommended).
|
||||
- **Modify:** `tools/brain-retro-analyzer.mjs` — строка 202 фильтр `=== 2 → >= 2`; добавить `recommended_node_for_direct` в `FACTOR_FNS`.
|
||||
- **Modify:** `tools/brain-retro-analyzer.test.mjs` — +1 case (mix v2 + v3).
|
||||
- **Modify:** `tools/missed-activations.mjs` — строка 22 фильтр `!== 2 → < 2` (чтобы v3 тоже попадал).
|
||||
- **Modify:** `tools/missed-activations.test.mjs` — +1 case (v3 episode).
|
||||
- **Modify:** `.claude/skills/brain-retro/references/aggregation-template.md` — +Hook script breakdown section + Missed Activations note про `recommended_node`.
|
||||
- **Modify:** `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` — добавить cross-ref note внизу: «schema v3 → 2026-05-23-observer-parser-skill-hook-expand-design.md».
|
||||
|
||||
---
|
||||
|
||||
## Task 1: observer-hook-resolver.mjs + tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tools/observer-hook-resolver.mjs`
|
||||
- Create: `tools/observer-hook-resolver.test.mjs`
|
||||
|
||||
- [ ] **Step 1.1: Создать failing test file**
|
||||
|
||||
Create `tools/observer-hook-resolver.test.mjs`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildHookMap, resolveScriptCounts, extractScriptName } from './observer-hook-resolver.mjs';
|
||||
|
||||
describe('extractScriptName', () => {
|
||||
it('extracts tools/X.mjs from "node tools/observer-stop-hook.mjs"', () => {
|
||||
expect(extractScriptName('node tools/observer-stop-hook.mjs')).toBe('tools/observer-stop-hook.mjs');
|
||||
});
|
||||
|
||||
it('extracts tools/X.mjs from quoted path with cwd', () => {
|
||||
expect(extractScriptName('node "C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs"'))
|
||||
.toBe('tools/subagent-prompt-prefix.mjs');
|
||||
});
|
||||
|
||||
it('extracts npx package name', () => {
|
||||
expect(extractScriptName('npx -y markdownlint-cli2 --fix file.md')).toBe('markdownlint-cli2');
|
||||
});
|
||||
|
||||
it('falls back to inline:<sha-16> for node -e inline scripts', () => {
|
||||
const result = extractScriptName('node -e "const f=process.env.X; if(f) process.stderr.write(\'warn\');"');
|
||||
expect(result).toMatch(/^inline:[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('inline fallback is stable across whitespace formatting', () => {
|
||||
const a = extractScriptName('node -e "const f = 1;\n\nif(f) process.exit(0);"');
|
||||
const b = extractScriptName('node -e "const f = 1; if(f) process.exit(0);"');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('inline fallback differs for different commands', () => {
|
||||
const a = extractScriptName('node -e "process.exit(0);"');
|
||||
const b = extractScriptName('node -e "process.exit(1);"');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHookMap', () => {
|
||||
it('returns empty Map for empty settings', () => {
|
||||
expect(buildHookMap({}).size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles missing hooks key', () => {
|
||||
expect(buildHookMap({ permissions: {} }).size).toBe(0);
|
||||
});
|
||||
|
||||
it('builds matcher → [scripts] for single-matcher single-script', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/foo.mjs' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const map = buildHookMap(settings);
|
||||
expect(map.get('PreToolUse:Bash')).toEqual(['tools/foo.mjs']);
|
||||
});
|
||||
|
||||
it('aggregates multiple scripts per matcher', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ matcher: 'Bash', hooks: [
|
||||
{ type: 'command', command: 'node tools/foo.mjs' },
|
||||
{ type: 'command', command: 'node tools/bar.mjs' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(buildHookMap(settings).get('PreToolUse:Bash')).toEqual(['tools/foo.mjs', 'tools/bar.mjs']);
|
||||
});
|
||||
|
||||
it('uses event name without matcher for UserPromptSubmit-style hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{ hooks: [{ type: 'command', command: 'node tools/economy.mjs' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(buildHookMap(settings).get('UserPromptSubmit')).toEqual(['tools/economy.mjs']);
|
||||
});
|
||||
|
||||
it('merges project + user settings (project takes precedence on dup matcher)', () => {
|
||||
const project = {
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/a.mjs' }] }] },
|
||||
};
|
||||
const user = {
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/b.mjs' }] }] },
|
||||
};
|
||||
const map = buildHookMap(project, user);
|
||||
// both contribute; project listed first
|
||||
expect(map.get('PreToolUse:Bash')).toEqual(['tools/a.mjs', 'tools/b.mjs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveScriptCounts', () => {
|
||||
it('returns {} for empty matcherCounts', () => {
|
||||
expect(resolveScriptCounts({}, new Map())).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when matcher not in map', () => {
|
||||
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, new Map())).toEqual({});
|
||||
});
|
||||
|
||||
it('duplicates count for each script on the matcher', () => {
|
||||
const map = new Map([['PreToolUse:Bash', ['tools/a.mjs', 'tools/b.mjs']]]);
|
||||
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, map)).toEqual({
|
||||
'tools/a.mjs': 5,
|
||||
'tools/b.mjs': 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('sums across multiple matchers that share a script', () => {
|
||||
const map = new Map([
|
||||
['PreToolUse:Bash', ['tools/x.mjs']],
|
||||
['PostToolUse:Bash', ['tools/x.mjs']],
|
||||
]);
|
||||
expect(resolveScriptCounts({ 'PreToolUse:Bash': 3, 'PostToolUse:Bash': 2 }, map))
|
||||
.toEqual({ 'tools/x.mjs': 5 });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: Run test — verify it fails**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- observer-hook-resolver
|
||||
```
|
||||
|
||||
Expected: FAIL — "Failed to load url ./observer-hook-resolver.mjs" or similar.
|
||||
|
||||
- [ ] **Step 1.3: Write implementation**
|
||||
|
||||
Create `tools/observer-hook-resolver.mjs`:
|
||||
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Hook resolver for the brain governance observer.
|
||||
* Reverse-lookup .claude/settings.json (+ ~/.claude/settings.json):
|
||||
* matcher (event:tool) → list of hook-script names.
|
||||
*
|
||||
* Pure — no exec, no fs side-effects (Security Guidance #40).
|
||||
* Caller is responsible for reading the JSON; this module operates on
|
||||
* already-parsed settings objects.
|
||||
*
|
||||
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const TOOL_SCRIPT_RE = /(?:^|[\s"'])(tools\/[\w-]+\.(?:mjs|py|sh))/;
|
||||
const NPX_RE = /(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)/;
|
||||
|
||||
/**
|
||||
* Normalize a command string for stable hashing:
|
||||
* - strip surrounding whitespace
|
||||
* - collapse internal whitespace runs to single space
|
||||
* No lowercase (script names are case-sensitive in Windows-aware contexts).
|
||||
*/
|
||||
function normalizeCommand(s) {
|
||||
return String(s || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a stable, human-readable identifier from a hook command string.
|
||||
* Priority: tools/X.{mjs,py,sh} → npx <pkg> → inline:<sha-16>.
|
||||
*/
|
||||
export function extractScriptName(command) {
|
||||
const cmd = String(command || '');
|
||||
const toolMatch = cmd.match(TOOL_SCRIPT_RE);
|
||||
if (toolMatch) return toolMatch[1];
|
||||
const npxMatch = cmd.match(NPX_RE);
|
||||
if (npxMatch) return npxMatch[1];
|
||||
const sha = createHash('sha256').update(normalizeCommand(cmd)).digest('hex').slice(0, 16);
|
||||
return `inline:${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build matcher → [scriptName, ...] from one or two settings objects.
|
||||
* Matcher key format:
|
||||
* - "<event>:<tool>" when entry has `matcher` (e.g. "PreToolUse:Bash")
|
||||
* - "<event>" when entry has no `matcher` (UserPromptSubmit, SessionStart)
|
||||
*
|
||||
* Project settings listed before user settings on shared matchers.
|
||||
*/
|
||||
export function buildHookMap(projectSettings = {}, userSettings = {}) {
|
||||
const map = new Map();
|
||||
for (const settings of [projectSettings, userSettings]) {
|
||||
const hooks = settings && settings.hooks;
|
||||
if (!hooks || typeof hooks !== 'object') continue;
|
||||
for (const [event, entries] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const matcher = entry.matcher ? `${event}:${entry.matcher}` : event;
|
||||
const scripts = Array.isArray(entry.hooks) ? entry.hooks : [];
|
||||
const existing = map.get(matcher) || [];
|
||||
for (const h of scripts) {
|
||||
if (!h || h.type !== 'command') continue;
|
||||
existing.push(extractScriptName(h.command));
|
||||
}
|
||||
map.set(matcher, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given matcher counts (from parser hook_fired.counts) and a hook map,
|
||||
* return per-script counts. Each script's count = sum over matchers that
|
||||
* include it of matcherCounts[matcher]. Matchers not in map are skipped
|
||||
* silently (their counts remain reflected in the original `counts` field).
|
||||
*/
|
||||
export function resolveScriptCounts(matcherCounts, hookMap) {
|
||||
const result = {};
|
||||
for (const [matcher, count] of Object.entries(matcherCounts || {})) {
|
||||
const scripts = hookMap.get(matcher);
|
||||
if (!scripts || scripts.length === 0) continue;
|
||||
for (const script of scripts) {
|
||||
result[script] = (result[script] || 0) + count;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1.4: Run test — verify it passes**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- observer-hook-resolver
|
||||
```
|
||||
|
||||
Expected: PASS — all describe/it green.
|
||||
|
||||
- [ ] **Step 1.5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/observer-hook-resolver.mjs tools/observer-hook-resolver.test.mjs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(observer): hook-resolver — matcher → script names (schema v3 prep)
|
||||
|
||||
Pure module. buildHookMap(project, user) reverse-lookup settings.json,
|
||||
resolveScriptCounts duplicates counts per script. No exec.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: observer-recommended-node.mjs + tests
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tools/observer-recommended-node.mjs`
|
||||
- Create: `tools/observer-recommended-node.test.mjs`
|
||||
|
||||
- [ ] **Step 2.1: Создать failing test file**
|
||||
|
||||
Create `tools/observer-recommended-node.test.mjs`:
|
||||
|
||||
```js
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { recommendNode } from './observer-recommended-node.mjs';
|
||||
|
||||
const MAP = {
|
||||
feature: ['#19'],
|
||||
refactor: ['#11', '#12', '#43'],
|
||||
question: [],
|
||||
other: [],
|
||||
};
|
||||
|
||||
describe('recommendNode', () => {
|
||||
it('returns first live node ID for a known classification', () => {
|
||||
expect(recommendNode('feature', MAP, { '#19': false })).toBe('#19');
|
||||
});
|
||||
|
||||
it('skips dormant first node, returns next live', () => {
|
||||
expect(recommendNode('refactor', MAP, { '#11': true, '#12': false, '#43': false })).toBe('#12');
|
||||
});
|
||||
|
||||
it('returns null when all recommended nodes are dormant', () => {
|
||||
expect(recommendNode('refactor', MAP, { '#11': true, '#12': true, '#43': true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for classification absent from map', () => {
|
||||
expect(recommendNode('nonexistent', MAP, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty-array classification (question/memory-sync)', () => {
|
||||
expect(recommendNode('question', MAP, {})).toBeNull();
|
||||
expect(recommendNode('other', MAP, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('treats missing dormancy entry as live (defensive, parity with missed-activations)', () => {
|
||||
// missed-activations uses dormancy[id] === false; recommendNode mirrors:
|
||||
// unknown/missing → not live (paranoid — only positive false counts as live).
|
||||
expect(recommendNode('feature', MAP, {})).toBeNull();
|
||||
});
|
||||
|
||||
it('handles null/undefined inputs without throwing', () => {
|
||||
expect(recommendNode(null, MAP, {})).toBeNull();
|
||||
expect(recommendNode('feature', null, {})).toBeNull();
|
||||
expect(recommendNode('feature', MAP, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Run test — verify it fails**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- observer-recommended-node
|
||||
```
|
||||
|
||||
Expected: FAIL — module not found.
|
||||
|
||||
- [ ] **Step 2.3: Write implementation**
|
||||
|
||||
Create `tools/observer-recommended-node.mjs`:
|
||||
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recommended-node resolver for direct episodes.
|
||||
* Pure — read-only, no exec, no fs (Security Guidance #40).
|
||||
*
|
||||
* For an episode classified as `taskClassification` with node_chosen='direct',
|
||||
* return the first live (non-dormant) recommended node ID from the
|
||||
* classification map. Mirrors missed-activations.mjs dormancy logic:
|
||||
* dormancy[id] === false strictly (missing/true → not live).
|
||||
*
|
||||
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
|
||||
*/
|
||||
|
||||
export function recommendNode(taskClassification, classificationMap, dormancy) {
|
||||
if (!taskClassification || !classificationMap || !dormancy) return null;
|
||||
const recommended = classificationMap[taskClassification];
|
||||
if (!Array.isArray(recommended) || recommended.length === 0) return null;
|
||||
for (const id of recommended) {
|
||||
if (dormancy[id] === false) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2.4: Run test — verify it passes**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- observer-recommended-node
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2.5: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/observer-recommended-node.mjs tools/observer-recommended-node.test.mjs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(observer): recommended-node resolver for direct episodes
|
||||
|
||||
Mirrors missed-activations dormancy logic (id === false strict).
|
||||
First live recommended node from classification-map, else null.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: parser extension + smoke
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/observer-transcript-parser.mjs`
|
||||
- Modify: `tools/observer-transcript-parser.test.mjs`
|
||||
|
||||
- [ ] **Step 3.1: Прочитать существующий test-файл и понять стиль фикстур**
|
||||
|
||||
```bash
|
||||
head -100 tools/observer-transcript-parser.test.mjs
|
||||
```
|
||||
|
||||
Идентифицировать существующие фабрики (`makeUserPrompt`, `makeAssistantMsg`, или подобные) — переиспользовать.
|
||||
|
||||
- [ ] **Step 3.2: Добавить 3 failing tests**
|
||||
|
||||
В `tools/observer-transcript-parser.test.mjs` (append к существующему `describe('parseTranscript', ...)` блоку, или новый describe block):
|
||||
|
||||
```js
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
describe('parseTranscript v3 fields', () => {
|
||||
// helper: minimal valid transcript with one user prompt + one assistant + tool_use Skill
|
||||
// Adapt to existing fixture pattern in this file — fallback below if no helper exists.
|
||||
function transcriptWithSkill(skillName) {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'добавь endpoint /api/foo' },
|
||||
timestamp: '2026-05-23T10:00:00Z',
|
||||
uuid: 'u-1',
|
||||
sessionId: 'sess-1',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 't-1', name: 'Skill', input: { skill: skillName } },
|
||||
],
|
||||
},
|
||||
timestamp: '2026-05-23T10:00:01Z',
|
||||
uuid: 'u-2',
|
||||
sessionId: 'sess-1',
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function transcriptDirectFeature() {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'добавь новый endpoint /api/foo' },
|
||||
timestamp: '2026-05-23T10:00:00Z',
|
||||
uuid: 'u-1',
|
||||
sessionId: 'sess-1',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] },
|
||||
timestamp: '2026-05-23T10:00:01Z',
|
||||
uuid: 'u-2',
|
||||
sessionId: 'sess-1',
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function transcriptWithHookAttachment() {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'ls' },
|
||||
timestamp: '2026-05-23T10:00:00Z',
|
||||
uuid: 'u-1',
|
||||
sessionId: 'sess-1',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 't-1', name: 'Bash', input: { command: 'ls' } }],
|
||||
},
|
||||
timestamp: '2026-05-23T10:00:01Z',
|
||||
uuid: 'u-2',
|
||||
sessionId: 'sess-1',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'attachment',
|
||||
attachment: { type: 'hook_success', hookName: 'PreToolUse:Bash', hookEvent: 'PreToolUse' },
|
||||
timestamp: '2026-05-23T10:00:01Z',
|
||||
uuid: 'u-3',
|
||||
sessionId: 'sess-1',
|
||||
}),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
it('emits schema_version: 3', () => {
|
||||
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
|
||||
expect(ep.schema_version).toBe(3);
|
||||
});
|
||||
|
||||
it('sets recommended_node for direct feature-classified episode', () => {
|
||||
// Inject a tiny classification map + dormancy via module mock or by
|
||||
// relying on the real files. Simpler: read real files; expect '#19'.
|
||||
// (If parser uses dependency injection, prefer that.)
|
||||
const ep = parseTranscript(transcriptDirectFeature(), 'sess-1');
|
||||
expect(ep.primary_rationale.recommended_node).toBe('#19');
|
||||
});
|
||||
|
||||
it('recommended_node is null when a skill was invoked', () => {
|
||||
const ep = parseTranscript(transcriptWithSkill('superpowers:writing-plans'), 'sess-1');
|
||||
expect(ep.primary_rationale.recommended_node).toBeNull();
|
||||
});
|
||||
|
||||
it('hook_fired event includes both counts and scripts keys', () => {
|
||||
const ep = parseTranscript(transcriptWithHookAttachment(), 'sess-1');
|
||||
const hookEvent = ep.events.find((e) => e.kind === 'hook_fired');
|
||||
expect(hookEvent).toBeDefined();
|
||||
expect(hookEvent.counts).toBeDefined();
|
||||
expect(hookEvent.scripts).toBeDefined();
|
||||
expect(typeof hookEvent.scripts).toBe('object');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
NB: если существующий test-файл уже импортирует `parseTranscript` — переиспользовать. Иначе добавить `import { parseTranscript } from './observer-transcript-parser.mjs';`.
|
||||
|
||||
- [ ] **Step 3.3: Run tests — verify they fail**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- observer-transcript-parser
|
||||
```
|
||||
|
||||
Expected: 4 FAIL — `schema_version === 3`, `recommended_node` field absent, `hookEvent.scripts` absent.
|
||||
|
||||
- [ ] **Step 3.4: Modify parser**
|
||||
|
||||
В `tools/observer-transcript-parser.mjs`:
|
||||
|
||||
**Patch 1 (top, after existing imports):**
|
||||
|
||||
```js
|
||||
import { buildHookMap, resolveScriptCounts } from './observer-hook-resolver.mjs';
|
||||
import { recommendNode } from './observer-recommended-node.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
let HOOK_MAP = null;
|
||||
function getHookMap() {
|
||||
if (HOOK_MAP) return HOOK_MAP;
|
||||
const read = (p) => { try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return {}; } };
|
||||
HOOK_MAP = buildHookMap(read('.claude/settings.json'), read(join(homedir(), '.claude/settings.json')));
|
||||
return HOOK_MAP;
|
||||
}
|
||||
|
||||
let CLASSIFICATION_MAP = null;
|
||||
function getClassificationMap() {
|
||||
if (CLASSIFICATION_MAP) return CLASSIFICATION_MAP;
|
||||
try {
|
||||
CLASSIFICATION_MAP = JSON.parse(readFileSync('tools/observer-classification-map.json', 'utf-8')).map || {};
|
||||
} catch { CLASSIFICATION_MAP = {}; }
|
||||
return CLASSIFICATION_MAP;
|
||||
}
|
||||
|
||||
let DORMANCY = null;
|
||||
function getDormancy() {
|
||||
if (DORMANCY) return DORMANCY;
|
||||
try { DORMANCY = JSON.parse(readFileSync('tools/.node-dormancy.json', 'utf-8')); }
|
||||
catch { DORMANCY = {}; }
|
||||
return DORMANCY;
|
||||
}
|
||||
```
|
||||
|
||||
**Patch 2 (`extractProcessEvents` — replace the hook_fired emit block):**
|
||||
|
||||
Locate:
|
||||
|
||||
```js
|
||||
if (Object.keys(hookCounts).length > 0) {
|
||||
events.push({ kind: 'hook_fired', counts: hookCounts, errors: hookErrors });
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```js
|
||||
if (Object.keys(hookCounts).length > 0) {
|
||||
const scripts = resolveScriptCounts(hookCounts, getHookMap());
|
||||
events.push({ kind: 'hook_fired', counts: hookCounts, scripts, errors: hookErrors });
|
||||
}
|
||||
```
|
||||
|
||||
**Patch 3 (`parseTranscript` — bump schema_version + add `recommended_node`):**
|
||||
|
||||
Locate `schema_version: 2,` in the returned object — change to `schema_version: 3,`.
|
||||
|
||||
Locate the `primary_rationale` IIFE return object. Inside that object, after `task_classification: classifyTask(prompt),` add:
|
||||
|
||||
```js
|
||||
recommended_node:
|
||||
skills.length === 0
|
||||
? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy())
|
||||
: null,
|
||||
```
|
||||
|
||||
- [ ] **Step 3.5: Run tests — verify they pass**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- observer-transcript-parser
|
||||
```
|
||||
|
||||
Expected: PASS — все 4 новых case + все существующие.
|
||||
|
||||
- [ ] **Step 3.6: Smoke на живом JSONL**
|
||||
|
||||
```bash
|
||||
node -e "import('./tools/observer-transcript-parser.mjs').then(m => { const c = require('fs').readFileSync('docs/observer/episodes-2026-05.jsonl', 'utf-8'); /* just ensure parser loads and exports are intact */ console.log('parser loaded, parseTranscript=', typeof m.parseTranscript); })"
|
||||
```
|
||||
|
||||
Expected: `parser loaded, parseTranscript= function` — без throw.
|
||||
|
||||
Note: парсер потребляет transcript-формат (`~/.claude/projects/.../*.jsonl`), не output-формат (`docs/observer/episodes-*.jsonl`). Smoke лишь проверяет, что модуль грузится с новыми импортами. Полная end-to-end проверка — следующий Stop-хук на реальной сессии.
|
||||
|
||||
- [ ] **Step 3.7: Run full tools test suite — regression check**
|
||||
|
||||
```bash
|
||||
npm run test:tools
|
||||
```
|
||||
|
||||
Expected: all green, including `observer-of-observer`, `observer-coverage-checker`, `missed-activations`, и т.д. (missed-activations пока ещё фильтрует `!== 2` — отдельная задача 4, регрессия не ожидается).
|
||||
|
||||
- [ ] **Step 3.8: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/observer-transcript-parser.mjs tools/observer-transcript-parser.test.mjs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(observer): parser v3 — hook_fired.scripts + recommended_node
|
||||
|
||||
schema_version 2 → 3. hook_fired event now carries `scripts` map
|
||||
(reverse-lookup .claude/settings.json + user). primary_rationale gets
|
||||
`recommended_node` (Tooling node ID) for direct episodes via
|
||||
classification-map + dormancy. Existing `counts`/skill paths unchanged
|
||||
— backward-compat preserved.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: analyzer >=2 + factor axis + missed-activations <2
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `tools/brain-retro-analyzer.mjs`
|
||||
- Modify: `tools/brain-retro-analyzer.test.mjs`
|
||||
- Modify: `tools/missed-activations.mjs`
|
||||
- Modify: `tools/missed-activations.test.mjs`
|
||||
|
||||
- [ ] **Step 4.1: Добавить failing tests**
|
||||
|
||||
В `tools/brain-retro-analyzer.test.mjs` (append):
|
||||
|
||||
```js
|
||||
describe('analyze: schema_version filter', () => {
|
||||
it('accepts both v2 and v3 episodes', () => {
|
||||
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
|
||||
const v3 = { ...v2, schema_version: 3, primary_rationale: { ...v2.primary_rationale, recommended_node: '#19' } };
|
||||
const result = analyze([v2, v3]);
|
||||
expect(result.episodeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('factorMatrix has recommended_node_for_direct axis', () => {
|
||||
const v3 = { schema_version: 3, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
|
||||
const result = analyze([v3]);
|
||||
expect(result.factorMatrix.recommended_node_for_direct).toBeDefined();
|
||||
expect(result.factorMatrix.recommended_node_for_direct['#19']).toBeDefined();
|
||||
});
|
||||
|
||||
it('v2 episode bucket=none in recommended_node_for_direct', () => {
|
||||
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
|
||||
const result = analyze([v2]);
|
||||
expect(result.factorMatrix.recommended_node_for_direct.none).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
В `tools/missed-activations.test.mjs` (append):
|
||||
|
||||
```js
|
||||
it('detects missed activation on v3 episode', () => {
|
||||
const v3 = { schema_version: 3, primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' } };
|
||||
const result = detectMissedActivations([v3], { feature: ['#19'] }, { '#19': false });
|
||||
expect(result.totalMissed).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: Run tests — verify they fail**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- brain-retro-analyzer missed-activations
|
||||
```
|
||||
|
||||
Expected: FAIL — `recommended_node_for_direct` missing; v3 not counted.
|
||||
|
||||
- [ ] **Step 4.3: Modify analyzer**
|
||||
|
||||
В `tools/brain-retro-analyzer.mjs` строка 202:
|
||||
|
||||
```js
|
||||
const normal = allNormal.filter((e) => e.schema_version === 2);
|
||||
```
|
||||
|
||||
→
|
||||
|
||||
```js
|
||||
const normal = allNormal.filter((e) => e.schema_version >= 2);
|
||||
```
|
||||
|
||||
В `FACTOR_FNS` (object literal): добавить запись после `task_classification`:
|
||||
|
||||
```js
|
||||
recommended_node_for_direct: (e) => (e.primary_rationale || {}).recommended_node || 'none',
|
||||
```
|
||||
|
||||
- [ ] **Step 4.4: Modify missed-activations**
|
||||
|
||||
В `tools/missed-activations.mjs` строка 22:
|
||||
|
||||
```js
|
||||
if (e.schema_version !== 2) continue;
|
||||
```
|
||||
|
||||
→
|
||||
|
||||
```js
|
||||
if (typeof e.schema_version !== 'number' || e.schema_version < 2) continue;
|
||||
```
|
||||
|
||||
Update doc-комментарий выше (строки 7-12), пункт 1:
|
||||
|
||||
```js
|
||||
* 1. schema_version >= 2 (v1 lacks factor data)
|
||||
```
|
||||
|
||||
- [ ] **Step 4.5: Run tests — verify they pass**
|
||||
|
||||
```bash
|
||||
npm run test:tools -- brain-retro-analyzer missed-activations
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4.6: Full regression**
|
||||
|
||||
```bash
|
||||
npm run test:tools
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 4.7: Smoke на живом JSONL**
|
||||
|
||||
```bash
|
||||
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl | head -40
|
||||
```
|
||||
|
||||
Expected: JSON output, ненулевой `episodeCount`, `factorMatrix.recommended_node_for_direct` присутствует (даже если только `'none'` bucket — все эпизоды v2).
|
||||
|
||||
- [ ] **Step 4.8: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/brain-retro-analyzer.mjs tools/brain-retro-analyzer.test.mjs \
|
||||
tools/missed-activations.mjs tools/missed-activations.test.mjs
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(observer): analyzer >=2 + recommended_node_for_direct factor axis
|
||||
|
||||
brain-retro-analyzer accepts schema_version >= 2 (v2+v3 mix).
|
||||
FACTOR_FNS +recommended_node_for_direct ('none' bucket for v2).
|
||||
missed-activations also raised to >= 2.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: brain-retro template + spec cross-ref
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `.claude/skills/brain-retro/references/aggregation-template.md`
|
||||
- Modify: `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
|
||||
|
||||
- [ ] **Step 5.1: Расширить aggregation-template.md**
|
||||
|
||||
В `.claude/skills/brain-retro/references/aggregation-template.md` после секции «Top nodes used (from `skill_invoked` events)» добавить:
|
||||
|
||||
```markdown
|
||||
## Hook script breakdown (from `hook_fired.scripts`, schema v3+)
|
||||
|
||||
Per-script counts across the period. Surfaces which discipline-enforcing hooks fired (and which silently failed to fire). Aggregate from `events[].hook_fired.scripts` of v3 episodes — v2 episodes have only matcher-level `counts` and contribute nothing here.
|
||||
|
||||
| script | times fired | notes |
|
||||
|---|---|---|
|
||||
| `tools/observer-stop-hook.mjs` | N | should fire once per turn — gaps = observer drop |
|
||||
| `tools/subagent-prompt-prefix.mjs` | N | once per Task-tool call |
|
||||
| `inline:<sha-16>` | N | inline `node -e "..."` — see settings.json for body |
|
||||
|
||||
**Discipline highlights:**
|
||||
|
||||
- `tools/observer-stop-hook.mjs` count < turn count → observer skipped turns; cross-check `observerErrorCount` and STATUS.md C5.
|
||||
- `tools/subagent-prompt-prefix.mjs` count vs `Agent` tool_use count — mismatch = missing pre-flight injection.
|
||||
- Inline `claude-md`/`schema.sql` guards — fired iff someone touched those files.
|
||||
|
||||
## Recommended-node candidates (from `primary_rationale.recommended_node`, schema v3+)
|
||||
|
||||
Distinct from `missedActivations` (which aggregates): this is the per-episode signal embedded in each direct episode.
|
||||
|
||||
| recommended_node | times direct | top classifications |
|
||||
|---|---|---|
|
||||
| #19 | N | feature, planning |
|
||||
| none (v2 or no recommendation) | N | — |
|
||||
|
||||
Cross-reference with `factorMatrix.recommended_node_for_direct` and `missedActivations.byNode`. A persistent (#NN, count > threshold) — strong missed-activation pattern, candidate for retro discussion.
|
||||
```
|
||||
|
||||
- [ ] **Step 5.2: Расширить Missed Activations section в template**
|
||||
|
||||
В существующей секции «Missed Activations (Pravila §16.4 v1.36)», в конце добавить:
|
||||
|
||||
```markdown
|
||||
|
||||
**Schema v3 NB:** since 2026-05-23, each direct episode carries `primary_rationale.recommended_node` directly. The analyzer's `missedActivations` aggregates these into `byNode`/`byClassification`. For per-episode forensics (which prompt, which session), grep episodes-*.jsonl on `"recommended_node":"#NN"`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Cross-ref note в factor-analysis spec**
|
||||
|
||||
В конце `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` добавить (если нет секции «Amendments» — создать):
|
||||
|
||||
```markdown
|
||||
|
||||
## Amendments
|
||||
|
||||
### 2026-05-23 — schema v3 (parser skill/hook expand)
|
||||
|
||||
Spec extension: forward-only bump `schema_version` 2 → 3. Two new fields:
|
||||
|
||||
- `events[].hook_fired.scripts: { script_name: count, ... }` — reverse-lookup `.claude/settings.json` → имена хук-скриптов. Old `counts` (matcher level) preserved для backward-compat.
|
||||
- `primary_rationale.recommended_node: "#NN" | null` — для direct-эпизодов derived из `classification-map` + dormancy. null при использованном skill / отсутствии рекомендации / всех dormant.
|
||||
|
||||
Analyzer фильтр `schema_version === 2` → `>= 2`; `missed-activations` фильтр `!== 2` → `< 2`. FACTOR_FNS +recommended_node_for_direct.
|
||||
|
||||
Полный spec: `docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5.4: Markdownlint + cspell**
|
||||
|
||||
```bash
|
||||
npx markdownlint-cli2 --fix \
|
||||
.claude/skills/brain-retro/references/aggregation-template.md \
|
||||
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md
|
||||
```
|
||||
|
||||
Если cspell ругнётся на новые термины — добавить в `cspell-words.txt`.
|
||||
|
||||
- [ ] **Step 5.5: Commit**
|
||||
|
||||
```bash
|
||||
git add .claude/skills/brain-retro/references/aggregation-template.md \
|
||||
docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md \
|
||||
cspell-words.txt
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(observer): brain-retro template +hook breakdown + recommended_node
|
||||
|
||||
aggregation-template.md gets two new sections (Hook script breakdown,
|
||||
Recommended-node candidates). factor-analysis spec gets a v3 amendment
|
||||
cross-ref to the 2026-05-23 spec.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review checklist (после Task 5, перед handoff)
|
||||
|
||||
- [ ] **Spec coverage:** Каждая секция spec'а покрыта?
|
||||
- hook-resolver → Task 1 ✓
|
||||
- recommended-node → Task 2 ✓
|
||||
- parser extension + schema v3 + smoke → Task 3 ✓
|
||||
- analyzer >=2 + factor axis + missed-activations <2 → Task 4 ✓
|
||||
- template + cross-ref → Task 5 ✓
|
||||
- [ ] **Pravila §15.2** Pre-flight sync — Task 0 ✓
|
||||
- [ ] **Security Guidance #40** — no exec/execSync — все 3 модуля + parser delta используют только readFileSync + JSON.parse + regex ✓
|
||||
- [ ] **Type consistency:** `recommendNode` (camelCase) везде; `recommended_node` (snake_case) в episode/spec — паттерн `recommendNode → recommended_node` consistent ✓
|
||||
|
||||
---
|
||||
|
||||
## Risks during execution
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Existing parser test использует custom helper, не описанный здесь | Step 3.1 — прочитать существующий тест-файл, переиспользовать helper. Fallback transcript-фикстуры в Step 3.2 — самодостаточные. |
|
||||
| `recommendNode` через DI vs file-read — тест из Step 3.2 ожидает реальный classification-map | Тест использует реальный `tools/observer-classification-map.json` (он стабилен и commited). `feature: ['#19']` — это факт, проверено в Step 0 exploration. Dormancy `.node-dormancy.json` — `#19` non-dormant. |
|
||||
| lefthook pre-commit может ругнуться на `.mjs` (eslint-vue ignorePaths) | tools/*.mjs уже исключены в lefthook конфиге (прецедент: 32 существующих .mjs скрипта). Если ругнётся — проверить lefthook конфиг до коммита. |
|
||||
| Изменение `missed-activations` фильтра ломает существующие missed-activation тесты | Тесты в missed-activations.test.mjs используют `schema_version: 2` явно — `>= 2` для них тоже true. Backward-compat preserved. |
|
||||
| Параллельная Claude-сессия трогает те же файлы | Pre-flight Task 0; если detected — STOP, ребейз/мердж. |
|
||||
|
||||
---
|
||||
|
||||
## Execution
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-23-observer-parser-skill-hook-expand.md`. Two execution options:**
|
||||
|
||||
**1. Subagent-Driven (recommended)** — controller (этот session) dispatches Sonnet/Opus subagent per Task (Pravila §15.1), reviews commit between Tasks; fast iteration, isolated context per subagent.
|
||||
|
||||
**2. Inline Execution** — Tasks 1-5 в этой же session через executing-plans skill; batch с checkpoint между Tasks.
|
||||
|
||||
**Какой подход?**
|
||||
File diff suppressed because it is too large
Load Diff
@@ -322,7 +322,7 @@ REVISION). Worktree: `.claude/worktrees/observer-v2-expansion`, ветка
|
||||
### 12.4. STATUS.md generator
|
||||
|
||||
- **Real PII counter** (#3 SIMPLIFIED): `sanitizeWithCount` в pii-filter
|
||||
+ persistent `docs/observer/.pii-counters.json` (per-month aggregation,
|
||||
- persistent `docs/observer/.pii-counters.json` (per-month aggregation,
|
||||
bumped on each Stop-hook write) + `countPiiMatches()` reads counter.
|
||||
STATUS перестаёт врать `0 PII matches`. PII patterns themselves NOT
|
||||
changed (F7 of parallel session already extended).
|
||||
@@ -367,3 +367,16 @@ REVISION). Worktree: `.claude/worktrees/observer-v2-expansion`, ветка
|
||||
|
||||
После всех 18 task'ов: **NNN/NNN GREEN** в `npm run test:tools`
|
||||
(baseline 232 → final NNN — заполнить в финальном commit Task 21).
|
||||
|
||||
## Amendments
|
||||
|
||||
### 2026-05-23 — schema v3 (parser skill/hook expand)
|
||||
|
||||
Spec extension: forward-only bump `schema_version` 2 → 3. Two new fields:
|
||||
|
||||
- `events[].hook_fired.scripts: { script_name: count, ... }` — reverse-lookup `.claude/settings.json` → имена хук-скриптов. Old `counts` (matcher level) preserved для backward-compat.
|
||||
- `primary_rationale.recommended_node: "#NN" | null` — для direct-эпизодов derived из `classification-map` + dormancy. null при использованном skill / отсутствии рекомендации / всех dormant.
|
||||
|
||||
Analyzer фильтр `schema_version === 2` → `>= 2`; `missed-activations` фильтр `!== 2` → `< 2`. FACTOR_FNS +recommended_node_for_direct.
|
||||
|
||||
Полный spec: `docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md`.
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
# Спек A — Биллинг v2: единый ₽-баланс + унификация tariff_plans
|
||||
|
||||
**Дата:** 2026-05-23
|
||||
**Статус:** Design (awaiting user review)
|
||||
**Автор:** Claude Opus 4.7 (под руководством заказчика)
|
||||
**Брейнсторм:** сессия 23.05.2026
|
||||
**Триггер:** «баланс только в рублях и перевести его в лиды в соответствии с тарифом, клиент видит и то и то» + аудит раздела «Биллинг» с 19 находками.
|
||||
|
||||
**Часть серии из 3 спеков:**
|
||||
|
||||
- **Спек A (этот)** — балансовая модель + аудит UI.
|
||||
- Спек B — дубли (`DuplicateDetector` ↔ кросс-месячные кейсы).
|
||||
- Спек C — preflight баланса + остановка всех проектов + пересчёт заказа поставщику + VTB-эквайринг.
|
||||
|
||||
---
|
||||
|
||||
## §1. Контекст и проблема
|
||||
|
||||
### §1.1 Текущая модель
|
||||
|
||||
Сейчас у тенанта **два баланса** ([db/schema.sql](../../../db/schema.sql) таблица `tenants`):
|
||||
|
||||
- `balance_leads` (INTEGER) — предоплаченные лиды поштучно.
|
||||
- `balance_rub` (DECIMAL) — рублёвый баланс.
|
||||
|
||||
При доставке лида ([LedgerService::chargeForDelivery](../../../app/app/Services/Billing/LedgerService.php)):
|
||||
|
||||
1. Подбирается ступень из `pricing_tiers` (7 ступеней объёмного тарифа).
|
||||
2. Если `balance_leads >= 1` → списываем 1 лид, цена `lead_charges.price_per_lead_kopecks=0`, `charge_source='prepaid'`.
|
||||
3. Иначе — списываем рубли по цене ступени, `charge_source='rub'`.
|
||||
|
||||
Параллельно в `tariff_plans` есть колонки `price_per_lead`, `price_monthly`, `included_leads`, `trial_bonus_leads`, `billing_model` — второе понятие «цены за лид» и «включённых лидов», которое не используется в горячем пути (`LedgerService` смотрит только `pricing_tiers`), но висит в схеме и читается из API.
|
||||
|
||||
### §1.2 Проблемы
|
||||
|
||||
1. Клиенту трудно понять «сколько лидов у меня хватит» — два кошелька с разными правилами трат.
|
||||
2. Концепция «предоплаченных лидов» (`balance_leads`) дублирует ту же ценность, что и `balance_rub`, но в другой валюте.
|
||||
3. `tariff_plans.price_per_lead` ↔ `pricing_tiers.price_per_lead_kopecks` — конфликт источников истины.
|
||||
4. UI раздела «Биллинг» содержит 19 формальных находок (см. §7).
|
||||
5. Концепция «включённых лидов» (`included_leads`) при подписочной модели (`billing_model='monthly'`/`'hybrid'`) — мёртвый код.
|
||||
|
||||
### §1.3 Триггер
|
||||
|
||||
Заказчик 23.05.2026: «**баланс только в рублях и перевести его в лиды в соответствии с тарифом, клиент видит и то и то**». Дальше через брейнсторм согласован Approach 3 — «Чистый разрез + унификация tariff_plans».
|
||||
|
||||
---
|
||||
|
||||
## §2. Решение
|
||||
|
||||
**Подход:** единый ₽-баланс, лиды — деривативом через pure-сервис, `tariff_plans` ужимается до «название и фичи».
|
||||
|
||||
### §2.1 Ключевые тезисы
|
||||
|
||||
1. **Единый ₽-баланс.** Колонка `tenants.balance_leads` удаляется. Существующие ненулевые остатки конвертируются в `balance_rub` по цене ступени 1 (консервативно, в пользу клиента) одноразовой artisan-командой.
|
||||
2. **Лиды — деривативом** через pure-сервис `BalanceToLeadsConverter`. Точный расчёт по ступеням: сколько лидов клиент реально получит при текущем балансе, учитывая уже доставленные за месяц и пересечения ступеней.
|
||||
3. **`tariff_plans` — только название и фичи.** Колонки `price_per_lead`, `price_monthly`, `included_leads`, `trial_bonus_leads`, `billing_model` удаляются. Все цены — только из `pricing_tiers`.
|
||||
4. **Никаких возвратов** (`refund`). Соответствующий таб/фильтр удаляются. (Если бизнес-нужда подтвердится — отдельный спек.)
|
||||
5. **Все P0/P1/P2 находки реестра** (§7) закрываются в рамках этого спека.
|
||||
|
||||
### §2.2 Что НЕ делаем (явно — out of scope)
|
||||
|
||||
- VTB-эквайринг и реальная оплата → **спек C**.
|
||||
- Auto-stop всех проектов клиента при нехватке баланса + пересчёт заказа у поставщика → **спек C**.
|
||||
- Дубли (`DuplicateDetector` 24h окно, кросс-месячные кейсы) → **спек B**.
|
||||
- Сверка с поставщиком CSV (`CsvReconcileJob`) — не трогаем.
|
||||
- `SupplierQuotaAllocator::computeOrder` — не трогаем.
|
||||
- Возвраты (`refund`) — не реализуем.
|
||||
|
||||
---
|
||||
|
||||
## §3. Архитектура
|
||||
|
||||
### §3.1 Карта изменений
|
||||
|
||||
| Слой | Что |
|
||||
|---|---|
|
||||
| **БД** | `tenants` (DROP `balance_leads`), `tariff_plans` (DROP 5 колонок), `balance_transactions` (новый `type='migration'`), `lead_charges` (без изменений в схеме) |
|
||||
| **Бэк-сервисы** | `LedgerService` (упрощается), `BillingTopupService` (без изменений), **новый** `BalanceToLeadsConverter` (pure) |
|
||||
| **Бэк-контроллеры** | `BillingController` (wallet + transactions), `TenantChargesController` (export), `AdminPricingTiersController` (bcmul fix) |
|
||||
| **Бэк-команды** | **новая** `BillingMigrateLeadsToRubCommand` (artisan, идемпотентная) |
|
||||
| **Фронт-страница** | `BillingView`, `views/billing/ChargesTab` |
|
||||
| **Фронт-компоненты** | `BalanceCard`, `TransactionsTable`, `InvoicesTable`, `TopupDialog` (минимально) |
|
||||
| **Новый UI** | `TierPricesPanel` (7-ступенчатая таблица с подсветкой текущей, сворачиваемая) |
|
||||
| **Seeders** | `DemoSeeder`, `TenantSeeder` (если ссылаются на удаляемые поля) |
|
||||
| **Тесты** | Pest +3 новых файла, ~6 обновляемых; Vitest +1, ~4 обновляемых; Histoire +1, ~2 обновляемых |
|
||||
|
||||
### §3.2 Изменения схемы БД
|
||||
|
||||
#### §3.2.1 Phase 1 — data migration (artisan-команда)
|
||||
|
||||
Команда `php artisan billing:migrate-leads-to-rub`:
|
||||
|
||||
```
|
||||
ДЛЯ КАЖДОГО tenant С balance_leads > 0:
|
||||
В транзакции с lockForUpdate(tenant):
|
||||
1. Если balance_leads <= 0 → no-op (идемпотентность).
|
||||
2. migrated_kopecks := balance_leads × pricing_tiers[tier_no=1, активная на сегодня].price_per_lead_kopecks
|
||||
migrated_rub := bcdiv(migrated_kopecks, '100', 2)
|
||||
3. new_balance_rub := bcadd(balance_rub, migrated_rub, 2)
|
||||
4. UPDATE tenants SET balance_rub = new_balance_rub, balance_leads = 0 WHERE id = tenant.id
|
||||
5. INSERT balance_transactions(
|
||||
type = 'migration',
|
||||
amount_leads = -balance_leads,
|
||||
amount_rub = '+' || migrated_rub,
|
||||
balance_leads_after = 0,
|
||||
balance_rub_after = new_balance_rub,
|
||||
description = 'Конвертация предоплаченных лидов в ₽ (миграция модели биллинга)',
|
||||
created_at = now()
|
||||
)
|
||||
```
|
||||
|
||||
Свойства:
|
||||
|
||||
- **Идемпотентна:** повторный запуск — no-op (проверка `balance_leads > 0`).
|
||||
- **Аудит:** одна `balance_transactions(type='migration')` на тенанта — единственный пейпер-трейл.
|
||||
- **Защита:** lockForUpdate против параллельных списаний/пополнений.
|
||||
|
||||
#### §3.2.2 Phase 2 — schema cleanup (отдельный коммит, **после кодовой части в проде**)
|
||||
|
||||
Миграция Laravel:
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenants DROP COLUMN balance_leads;
|
||||
|
||||
ALTER TABLE tariff_plans
|
||||
DROP COLUMN price_per_lead,
|
||||
DROP COLUMN price_monthly,
|
||||
DROP COLUMN included_leads,
|
||||
DROP COLUMN trial_bonus_leads,
|
||||
DROP COLUMN billing_model;
|
||||
|
||||
-- balance_transactions.amount_leads — остаётся nullable INT навсегда (история).
|
||||
-- lead_charges.charge_source + chk_lead_charges_prepaid_zero_price — остаются (история).
|
||||
-- pricing_tiers — без изменений.
|
||||
-- balance_transactions hash-chain триггеры — не трогаем.
|
||||
```
|
||||
|
||||
После Phase 2: `tariff_plans` содержит только `id, code, name, description, features (jsonb), limits (jsonb), is_active, is_public, sort_order, created_at, updated_at`. Превращается из «тарифного плана» в «пакет фич/лимитов».
|
||||
|
||||
#### §3.2.3 Новые константы
|
||||
|
||||
- `BalanceTransaction::TYPE_MIGRATION = 'migration'` (добавляем).
|
||||
- `BalanceTransaction::TYPE_REFUND` — **не вводим** (возвратов нет в этом спеке).
|
||||
|
||||
### §3.3 Изменения бэка
|
||||
|
||||
#### §3.3.1 Новый pure-сервис `BalanceToLeadsConverter`
|
||||
|
||||
Файл: `app/app/Services/Billing/BalanceToLeadsConverter.php`.
|
||||
|
||||
Сигнатура:
|
||||
|
||||
```php
|
||||
final class BalanceToLeadsConverter
|
||||
{
|
||||
/**
|
||||
* @param string $balanceRub DECIMAL-строка («5000.00»), bcmath
|
||||
* @param int $deliveredInMonth tenants.delivered_in_month
|
||||
* @param Collection<int, PricingTier> $activeTiers
|
||||
* @return array{
|
||||
* leads: int,
|
||||
* breakdown: list<array{tier_no:int, leads:int, price_rub:string}>,
|
||||
* current_tier: array{no:int, price_rub:string, leads_left_in_tier:int}|null,
|
||||
* next_tier: array{no:int, price_rub:string, leads_in_tier:int}|null
|
||||
* }
|
||||
*/
|
||||
public function convert(string $balanceRub, int $deliveredInMonth, Collection $activeTiers): array;
|
||||
}
|
||||
```
|
||||
|
||||
Алгоритм (псевдокод):
|
||||
|
||||
```
|
||||
balance_kopecks := bcmul(balanceRub, '100', 0) # string-int
|
||||
sorted := tiers.sortBy('tier_no').values()
|
||||
total_leads := 0
|
||||
breakdown := []
|
||||
cumulative := 0 # сколько лидов покрыто пройденными ступенями (для определения «вы здесь»)
|
||||
|
||||
current_tier := null
|
||||
next_tier := null
|
||||
|
||||
ДЛЯ tier В sorted:
|
||||
tier_start := cumulative + 1
|
||||
tier_cap := (tier.leads_in_tier === null) ? INF : tier.leads_in_tier
|
||||
tier_end := cumulative + tier_cap
|
||||
|
||||
# сколько слотов в этой ступени ещё не «съедено» уже доставленными
|
||||
slots_left_in_tier := max(0, tier_end - max(tier_start - 1, deliveredInMonth))
|
||||
|
||||
# «текущая ступень» — первая, где (deliveredInMonth + 1) попадает
|
||||
ЕСЛИ current_tier IS null AND deliveredInMonth < tier_end:
|
||||
current_tier := { no: tier.tier_no, price_rub: tier.price_rub, leads_left_in_tier: slots_left_in_tier }
|
||||
|
||||
ЕСЛИ slots_left_in_tier <= 0:
|
||||
cumulative := tier_end
|
||||
ПРОДОЛЖИТЬ
|
||||
|
||||
price_kopecks := tier.price_per_lead_kopecks
|
||||
ЕСЛИ price_kopecks <= 0:
|
||||
# бесплатная ступень (теоретически — пока не используется)
|
||||
total_leads += slots_left_in_tier
|
||||
breakdown.append({ tier_no, leads: slots_left_in_tier, price_rub: '0.00' })
|
||||
cumulative := tier_end
|
||||
ПРОДОЛЖИТЬ
|
||||
|
||||
# сколько лидов в этой ступени можем себе позволить
|
||||
affordable_in_tier := (int) bcdiv(balance_kopecks, price_kopecks, 0)
|
||||
take := min(slots_left_in_tier, affordable_in_tier)
|
||||
|
||||
ЕСЛИ take > 0:
|
||||
total_leads += take
|
||||
breakdown.append({ tier_no, leads: take, price_rub: format(price_kopecks) })
|
||||
balance_kopecks := bcsub(balance_kopecks, bcmul(price_kopecks, take, 0), 0)
|
||||
|
||||
ЕСЛИ take < slots_left_in_tier:
|
||||
# баланс кончился в этой ступени — следующей нет смысла
|
||||
# next_tier остаётся null (нет смысла показывать)
|
||||
ВЫЙТИ
|
||||
|
||||
cumulative := tier_end
|
||||
ЕСЛИ tier.leads_in_tier === null: ВЫЙТИ # «всё свыше»
|
||||
|
||||
# next_tier — следующая после current_tier
|
||||
next_idx := sorted.findIndex(t => t.tier_no > current_tier.no)
|
||||
ЕСЛИ next_idx !== -1:
|
||||
next_tier := { no: sorted[next_idx].tier_no, price_rub, leads_in_tier: sorted[next_idx].leads_in_tier }
|
||||
|
||||
ВЕРНУТЬ { leads: total_leads, breakdown, current_tier, next_tier }
|
||||
```
|
||||
|
||||
Деньги — bcmath, без PHP float. Pure (без БД-обращений). Тестируется изолированно.
|
||||
|
||||
#### §3.3.2 `LedgerService::chargeForDelivery` (упрощённый)
|
||||
|
||||
Удаляется dual-balance ветвление. Метод ужимается до:
|
||||
|
||||
```php
|
||||
public function chargeForDelivery(Tenant $lockedTenant, Deal $deal, ?SupplierLead $lead = null): ChargeResult
|
||||
{
|
||||
$activeTiers = $this->tiers->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1);
|
||||
$priceKopecks = (int) $tier->price_per_lead_kopecks;
|
||||
|
||||
// bcmath check: balance_rub × 100 >= priceKopecks
|
||||
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
|
||||
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: $priceKopecks,
|
||||
balanceRub: (string) $lockedTenant->balance_rub,
|
||||
);
|
||||
}
|
||||
|
||||
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
|
||||
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
|
||||
DB::table('tenants')->where('id', $lockedTenant->id)->update(['balance_rub' => $newBalanceRub]);
|
||||
$lockedTenant->increment('delivered_in_month', 1);
|
||||
$lockedTenant->refresh();
|
||||
|
||||
LeadCharge::create([
|
||||
'tenant_id' => $lockedTenant->id,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'tier_no' => $tier->tier_no,
|
||||
'price_per_lead_kopecks' => $priceKopecks,
|
||||
'charge_source' => 'rub', // всегда
|
||||
'charged_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $lockedTenant->id,
|
||||
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
||||
'amount_leads' => null, // история - больше не пишем
|
||||
'amount_rub' => '-' . $amountRub,
|
||||
'balance_leads_after' => null,
|
||||
'balance_rub_after' => (string) $lockedTenant->balance_rub,
|
||||
'related_type' => Deal::class,
|
||||
'related_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// supplier_lead_costs - без изменений
|
||||
if ($lead !== null) {
|
||||
$supplierId = $this->resolveSupplierId($lead);
|
||||
if ($supplierId !== null) {
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
DB::table('supplier_lead_costs')->insert([
|
||||
'deal_id' => $deal->id,
|
||||
'received_at' => $deal->received_at,
|
||||
'supplier_id' => $supplierId,
|
||||
'cost_rub' => $supplier->cost_rub,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return new ChargeResult('rub', $tier, $priceKopecks);
|
||||
}
|
||||
```
|
||||
|
||||
Удаляется:
|
||||
|
||||
- Приватный метод `decideSource()`.
|
||||
- Поле `ChargeResult::$source` (или всегда `'rub'`).
|
||||
- Параметр `InsufficientBalanceException::$balanceLeads`.
|
||||
|
||||
#### §3.3.3 `BillingController::wallet`
|
||||
|
||||
Новая структура ответа:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance_rub": "5000.00",
|
||||
"affordable_leads": 46,
|
||||
"current_tier": { "no": 1, "price_rub": "120.00", "leads_left_in_tier": 20 },
|
||||
"next_tier": { "no": 2, "price_rub": "100.00", "leads_in_tier": 100 },
|
||||
"delivered_in_month": 30,
|
||||
"runway_days": 12,
|
||||
"tiers_preview": [
|
||||
{ "tier_no": 1, "leads_in_tier": 50, "price_rub": "120.00" },
|
||||
{ "tier_no": 2, "leads_in_tier": 100, "price_rub": "100.00" },
|
||||
...
|
||||
{ "tier_no": 7, "leads_in_tier": null, "price_rub": "60.00" }
|
||||
],
|
||||
"tariff": { "code": "...", "name": "...", "features": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
`runway_days` пересчитывается как `affordable_leads / средний_лидов_в_день_за_30дн`. Если средняя = 0 → `null`. Если `affordable_leads = 0` → `0`. Одна формула для всего экрана.
|
||||
|
||||
`tariff` — без `price_monthly`, `billing_model`, `included_leads` (поля удалены).
|
||||
|
||||
#### §3.3.4 `BillingController::transactions`
|
||||
|
||||
Удалить фильтр `refund` из validation:
|
||||
|
||||
```diff
|
||||
- if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
|
||||
+ if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'migration'], true)) {
|
||||
```
|
||||
|
||||
#### §3.3.5 `AdminPricingTiersController::store`
|
||||
|
||||
```diff
|
||||
- 'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
|
||||
+ 'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
|
||||
- 'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100),
|
||||
+ 'price_per_lead_kopecks' => (int) bcmul((string) $tier['price_rub'], '100', 0),
|
||||
```
|
||||
|
||||
#### §3.3.6 `TenantChargesController::export`
|
||||
|
||||
Заполняем колонку `balance_rub_after` через JOIN к `balance_transactions`:
|
||||
|
||||
```sql
|
||||
JOIN balance_transactions bt ON bt.related_type = 'App\Models\Deal'
|
||||
AND bt.related_id = lead_charges.deal_id
|
||||
AND bt.tenant_id = lead_charges.tenant_id
|
||||
```
|
||||
|
||||
#### §3.3.7 Seeders cleanup
|
||||
|
||||
Перед миграцией `grep -r 'balance_leads\|trial_bonus_leads\|included_leads\|billing_model\|price_per_lead\|price_monthly' app/database/seeders/` — заменить все ссылки. Бонусные лиды при подключении тарифа выдаются как ₽ через `BillingTopupService::topup($tenantId, $startBonusRub, null)` с описанием «Стартовый бонус».
|
||||
|
||||
### §3.4 Изменения фронта
|
||||
|
||||
#### §3.4.1 Типы (`app/resources/js/api/billing.ts`)
|
||||
|
||||
```typescript
|
||||
export interface Wallet {
|
||||
balance_rub: string
|
||||
affordable_leads: number
|
||||
current_tier: { no: number; price_rub: string; leads_left_in_tier: number }
|
||||
next_tier: { no: number; price_rub: string; leads_in_tier: number } | null
|
||||
delivered_in_month: number
|
||||
runway_days: number | null
|
||||
tiers_preview: Array<{ tier_no: number; leads_in_tier: number | null; price_rub: string }>
|
||||
tariff: { code: string; name: string; features: string[] } | null
|
||||
}
|
||||
|
||||
export interface BillingTransaction {
|
||||
id: number
|
||||
code: string
|
||||
type: 'topup' | 'lead_charge' | 'migration' // 'refund' удалён
|
||||
description: string | null
|
||||
amount_rub: string
|
||||
amount_leads: number | null // история, может быть null
|
||||
balance_rub_after: string
|
||||
display_amount_rub: string // новое: всегда ₽-эквивалент (для исторических prepaid)
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
#### §3.4.2 `BillingView.vue`
|
||||
|
||||
- Шапка `page-stats`: удалить «N лидов запас». Остаётся «`X` кошелёк · хватит на `Y` дн.» (если `runway_days` не null).
|
||||
- Под `BalanceCard` — новый блок `TierPricesPanel` (см. §3.4.6), перед `TransactionsTable`.
|
||||
|
||||
#### §3.4.3 `BalanceCard.vue`
|
||||
|
||||
3 карточки:
|
||||
|
||||
| # | Заголовок | Контент |
|
||||
|---|---|---|
|
||||
| 1 | «Кошелёк ₽» (тёмная) | `balanceRub ₽` + мелким «мин. пополнение 100 ₽» (удалить «округление вниз ₽→лиды») + кнопка «Пополнить» + disabled «Автопополнение» |
|
||||
| 2 | «**≈ N лидов**» | `affordable_leads` крупно + tooltip «Точный расчёт по текущим ценам. Меняется при переходе ступеней.» + sub-line «сейчас по `current_tier.price_rub` ₽/лид» |
|
||||
| 3 | «Что входит» | `tariff.name` + список `tariff.features` (галочки). Без `price_monthly`. Кнопка «Сменить тариф» disabled остаётся. |
|
||||
|
||||
Удалить:
|
||||
|
||||
- «Баланс лидов (ГЦК)» текст.
|
||||
- Аббревиатуру «(ГЦК)».
|
||||
- Текст «округление вниз ₽→лиды».
|
||||
- Префикс `tariff_price` («₽/мес»).
|
||||
|
||||
#### §3.4.4 `TransactionsTable.vue`
|
||||
|
||||
- Массив `TABS` — удалить пункт `{ id: 'refund', ... }`.
|
||||
- Функция `txAmountText` — переписать: всегда выводит ₽-эквивалент через `display_amount_rub` (бэк отдаёт уже посчитанный).
|
||||
- `formatWhen` — добавить год: `{ year: '2-digit', day: '2-digit', month: '2-digit', hour, minute }` → «23.05.26, 14:30».
|
||||
|
||||
#### §3.4.5 `InvoicesTable.vue`
|
||||
|
||||
- Сумма с «₽»: `formatPlain(Number(inv.amount_total)) + ' ₽'`.
|
||||
- Empty-state без изменений («Счета появятся после первой оплаты»).
|
||||
|
||||
#### §3.4.6 `ChargesTab.vue`
|
||||
|
||||
- Удалить `v-select` «Источник» (`source` ref, `sources` массив).
|
||||
- Удалить колонку «Источник» из `headers`.
|
||||
- Колонка «Цена»: для исторических строк с `price_per_lead_kopecks === 0` (prepaid) — серое «0 ₽ (из бесплатного)» с tooltip «До перехода на новую модель эти лиды списывались из бесплатного остатка».
|
||||
- POST → GET для экспорта (находка #13) — отложено.
|
||||
|
||||
#### §3.4.7 `TopupDialog.vue`
|
||||
|
||||
В этом спеке **не трогаем** (VTB перекроит — спек C). Минимум 100₽ остаётся.
|
||||
|
||||
#### §3.4.8 Новый `TierPricesPanel.vue`
|
||||
|
||||
Свёрнутый по умолчанию `<v-expansion-panel>` с заголовком «Цены за лид (7 ступеней)». Внутри — таблица 7 строк:
|
||||
|
||||
| Ступень | Диапазон | Цена |
|
||||
|---|---|---|
|
||||
| 1 | 1–50 лидов | 120 ₽ |
|
||||
| 2 | 51–150 лидов | 100 ₽ |
|
||||
| ... | ... | ... |
|
||||
| 7 | 1501+ | 60 ₽ |
|
||||
|
||||
С подсветкой (бордер + чип «вы здесь») текущей ступени из `current_tier.no`. Данные — из `wallet.tiers_preview` (один API-запрос, не два).
|
||||
|
||||
### §3.5 Тесты
|
||||
|
||||
#### §3.5.1 Pest (новые)
|
||||
|
||||
- `Tests/Unit/Services/Billing/BalanceToLeadsConverterTest.php` — ≥8 кейсов (пустой баланс, одна ступень, переход ступеней, последняя `NULL`-ступень, `delivered_in_month` пропуск, граничные копейки, bcmath-точность, неактивные ступени).
|
||||
- `Tests/Feature/Billing/MigrationLeadsToRubTest.php` — конвертация по tier 1, INSERT `balance_transactions(type='migration')`, идемпотентность, lockForUpdate.
|
||||
- `Tests/Feature/Billing/WalletApiTest.php` — `/api/billing/wallet` отдаёт `affordable_leads`, `current_tier`, `next_tier`, `tiers_preview`, `tariff` без удалённых полей.
|
||||
|
||||
#### §3.5.2 Pest (обновляемые)
|
||||
|
||||
- `LedgerServiceTest` — удалить кейсы prepaid-ветки, оставить только rub.
|
||||
- `BillingControllerTest::transactions` — убрать кейс `type=refund`.
|
||||
- `AdminPricingTiersControllerTest` — кейс «цена 10.10 → 1010 копеек» через bcmul.
|
||||
- `TenantChargesControllerTest::export` — ассертить `balance_rub_after` заполнен.
|
||||
|
||||
#### §3.5.3 Pest (удаляемые)
|
||||
|
||||
- Все кейсы с `balance_leads--` или `charge_source='prepaid'` для **новых** сделок.
|
||||
|
||||
#### §3.5.4 Vitest
|
||||
|
||||
- `BalanceCard.spec.ts` — обновить (≈ N лидов, tooltip, без «(ГЦК)»).
|
||||
- `TransactionsTable.spec.ts` — без таба «Возвраты», конвертация через `display_amount_rub`.
|
||||
- `ChargesTab.spec.ts` — без фильтра/колонки «Источник».
|
||||
- `InvoicesTable.spec.ts` — формат суммы с «₽».
|
||||
- **Новый** `TierPricesPanel.spec.ts` — 7 ступеней рендерятся, текущая подсвечена.
|
||||
- `BillingView.spec.ts` — шапка без «лидов запас», `TierPricesPanel` свёрнут по умолчанию.
|
||||
|
||||
#### §3.5.5 Histoire
|
||||
|
||||
- `BillingView.story.vue`, `BalanceCard.story.vue` — обновить fixture'ы.
|
||||
- **Новый** `TierPricesPanel.story.vue` — 3 вариации (на ступени 1, 3, 7).
|
||||
|
||||
#### §3.5.6 Larastan / type-check
|
||||
|
||||
- Удалить `Tenant::balance_leads` свойство (PHPDoc + `$casts`).
|
||||
- vue-tsc после изменения `Wallet`-интерфейса найдёт все потребители — поправить точечно.
|
||||
|
||||
---
|
||||
|
||||
## §4. Миграция и релиз
|
||||
|
||||
### §4.1 Двухфазное развёртывание (критично)
|
||||
|
||||
#### Фаза A — код + data migration (PR #1)
|
||||
|
||||
1. Все code-side изменения (LedgerService, контроллеры, фронт, тесты, конвертер).
|
||||
2. Новая artisan-команда `php artisan billing:migrate-leads-to-rub`.
|
||||
3. **Колонка `balance_leads` остаётся в БД** — код её больше не читает/пишет, но физически на месте (страховка от мгновенного rollback).
|
||||
4. Прогон на проде:
|
||||
- бэкап БД (`pg_dump`),
|
||||
- деплой кода,
|
||||
- `php artisan billing:migrate-leads-to-rub`,
|
||||
- smoke-тесты на 2 demo тенантах (`/api/billing/wallet`, доставка тестового лида),
|
||||
- 24-72 ч наблюдения через `balance_transactions(type='migration')` audit-log.
|
||||
|
||||
#### Фаза B — schema cleanup (PR #2, через 1-3 дня после Фазы A в проде)
|
||||
|
||||
1. Grep-проверка: `grep -r 'balance_leads\|price_per_lead\|price_monthly\|included_leads\|trial_bonus_leads\|billing_model' app/` (исключая `lead_charges.price_per_lead_kopecks` — другое поле).
|
||||
2. Миграция Laravel `ALTER TABLE` (§3.2.2).
|
||||
3. Деплой.
|
||||
|
||||
**Rollback Фазы A:** `balance_leads` ещё в БД → обратный SQL по `balance_transactions.amount_leads` для строк `type='migration'`. Поэтому Фаза B — отдельный PR.
|
||||
|
||||
### §4.2 Регрессионные критерии (`/regression full` перед merge каждой фазы)
|
||||
|
||||
- Pest --parallel зелёный (целевое: +20-30 новых ассертов).
|
||||
- Vitest зелёный (+10-15 новых ассертов).
|
||||
- Larastan 0 ошибок.
|
||||
- Vite build OK.
|
||||
- Histoire build OK.
|
||||
- Pa11y `/billing` — 0 violations.
|
||||
- gitleaks 0, lychee 0 broken.
|
||||
|
||||
### §4.3 Контракты и инварианты
|
||||
|
||||
- **bcmath** для всех мутаций `balance_rub` (никогда PHP float).
|
||||
- **append-only** `balance_transactions` и `lead_charges` — hash-chain триггеры в БД не трогаем.
|
||||
- **Никогда** `balance_rub < 0` — `InsufficientBalanceException` перед мутацией.
|
||||
- **delivered_in_month** — единственный счётчик «лидов в этом месяце», обнуляется `ResetMonthlyCountersCommand` 1-го числа месяца.
|
||||
|
||||
---
|
||||
|
||||
## §5. Алгоритм конвертации `BalanceToLeadsConverter::convert` — рабочий пример
|
||||
|
||||
**Вход:**
|
||||
|
||||
- `balanceRub = '5000.00'`
|
||||
- `deliveredInMonth = 30`
|
||||
- `tiers`:
|
||||
- tier 1: leads_in_tier=50, price=120₽ (12000 коп)
|
||||
- tier 2: leads_in_tier=100, price=100₽ (10000 коп)
|
||||
- tier 3: leads_in_tier=200, price=80₽ (8000 коп)
|
||||
- ...
|
||||
- tier 7: leads_in_tier=NULL, price=60₽ (6000 коп)
|
||||
|
||||
**Прогон:**
|
||||
|
||||
- balance_kopecks = 500 000
|
||||
- **tier 1:** tier_start=1, tier_end=50, slots_left = 50−max(0, 30) = 20.
|
||||
- current_tier := { no:1, price:'120.00', leads_left:20 }
|
||||
- affordable_in_tier = floor(500000/12000) = 41 → take = min(20, 41) = 20
|
||||
- total = 20; balance_kopecks = 500000 − 20×12000 = 260000
|
||||
- take == slots_left → продолжаем; cumulative = 50.
|
||||
- **tier 2:** tier_start=51, tier_end=150, slots_left = 150−max(50, 30) = 100.
|
||||
- affordable = floor(260000/10000) = 26 → take = min(100, 26) = 26
|
||||
- total = 46; balance_kopecks = 260000 − 26×10000 = 0
|
||||
- take < slots_left → выход.
|
||||
- **Итог:** `{ leads: 46, breakdown: [{1, 20, '120.00'}, {2, 26, '100.00'}], current_tier: {1, '120.00', 20}, next_tier: {2, '100.00', 100} }`
|
||||
|
||||
UI: «**≈ 46 лидов**» крупно. Tooltip: «20 лидов по 120 ₽ + 26 по 100 ₽».
|
||||
|
||||
---
|
||||
|
||||
## §6. Реестр находок «Биллинг» (закрывается в этом спеке)
|
||||
|
||||
**P0 — критичные:**
|
||||
|
||||
- **№1.** «Баланс лидов (ГЦК)» карточка → «≈ N лидов» с tooltip. Убрать «(ГЦК)».
|
||||
- **№2.** Дубль `balance_leads` в шапке `BillingView` — удалить из `page-stats`.
|
||||
- **№3.** Таб «Возвраты» в `TransactionsTable` + фильтр `refund` в `BillingController::transactions` — удалить (без возвратов в этом спеке).
|
||||
- **№4.** Чип `prepaid` и фильтр «Источник» в `ChargesTab` — удалить (исторические строки помечаются tooltip'ом).
|
||||
- **№5.** `InvoicesTable.amount_total` без «₽» — добавить суффикс.
|
||||
|
||||
**P1 — важные:**
|
||||
|
||||
- **№6.** `BillingController::runwayDays` — переписать на `affordable_leads / средний_лидов_в_день` (одна формула с шапкой).
|
||||
- **№7.** `AdminPricingTiersController::store` — float → bcmul + `regex:/^\d+(\.\d{1,2})?$/` validation.
|
||||
- **№8.** «Округление вниз ₽→лиды» в `BalanceCard` — удалить (после конвертера термин не нужен).
|
||||
- **№9.** `TopupDialog` алерт «Платёжный шлюз...» — оставить как есть (VTB перекроит в спеке C).
|
||||
- **№10.** `TopupDialog.PRESETS` — синхронизировать с VTB после спека C; в этом спеке не трогаем.
|
||||
- **№11.** `txAmountText` «− 1 лид.» — переписать через `display_amount_rub` от бэка.
|
||||
|
||||
**P2 — нюансы:**
|
||||
|
||||
- **№12.** `TransactionsTable.formatWhen` — добавить год.
|
||||
- **№13.** `ChargesTab.exportCsv` POST → GET — отложено (не блокер).
|
||||
- **№14.** `TenantChargesController::export.balance_rub_after` пустой — заполнить через JOIN.
|
||||
- **№15.** `InvoicesTable.amount_total → Number()` precision — отложено (под VTB).
|
||||
|
||||
**Связанные (вне этого спека):**
|
||||
|
||||
- **№16.** `DuplicateDetector.WINDOW_HOURS = 24` → спек B.
|
||||
- **№17.** `SupplierQuotaAllocator::computeOrder` без учёта баланса → спек C.
|
||||
- **№18.** `RouteSupplierLeadJob::handleInsufficientBalance` останавливает один проект → спек C.
|
||||
- **№19.** `BillingTopupService` зачисляет сразу → спек C (VTB).
|
||||
|
||||
---
|
||||
|
||||
## §7. Открытые вопросы
|
||||
|
||||
Все решения согласованы в брейнсторме 23.05.2026:
|
||||
|
||||
- Вариант 3 (с унификацией `tariff_plans`) — выбран.
|
||||
- Точный расчёт по ступеням — выбран (не «по текущей ступени», не «по ступени 1»).
|
||||
- `balance_leads` удаляется полностью (не остаётся как «подарочный остаток»).
|
||||
- Возвраты — не реализуем.
|
||||
- Конвертер — pure, на бэке, один движок для шапки + карточки + runway.
|
||||
- `TierPricesPanel` — свёрнут по умолчанию.
|
||||
- `tiers_preview` встроен в `/api/billing/wallet` (один запрос).
|
||||
- Релиз — двухфазный (код+data → ALTER TABLE).
|
||||
- Миграция данных — artisan-команда, идемпотентная.
|
||||
|
||||
---
|
||||
|
||||
## §8. Связанные документы
|
||||
|
||||
- Брейнсторм-сессия: 23.05.2026 (transcript не сохранён отдельно — содержание этого спека отражает все решения).
|
||||
- Исходный дизайн биллинга: [docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md](2026-05-11-plan4-billing-csv-admin-design.md).
|
||||
- Спек B (дубли) — будет создан после Спека A.
|
||||
- Спек C (preflight + VTB) — будет создан после Спека B.
|
||||
|
||||
---
|
||||
|
||||
## §9. Следующие шаги
|
||||
|
||||
1. **Пользовательское ревью** этого спека.
|
||||
2. После одобрения — переход к `superpowers:writing-plans` для генерации детального плана реализации (`docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md`).
|
||||
3. Реализация по плану в отдельной ветке (предположительно `feat/billing-v2-spec-a`).
|
||||
4. Релиз Phase A → наблюдение → релиз Phase B.
|
||||
5. Переход к брейнсторму Спека C.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Дыра #2 — партиционирование 7 audit-таблиц (design)
|
||||
|
||||
**Версия:** v1.0 от 23.05.2026 (вечер)
|
||||
**Триггер:** последняя из 7 дыр аудита журналирования. Заказчик: «делай дальше».
|
||||
**План реализации:** `2026-05-23-hole-2-audit-partitioning-plan.md` (создаётся после approve этого spec).
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
Из аудита журналирования: 7 audit-таблиц **растут вечно** на проде без механизма retention. Цель — partitioning **по месяцам** чтобы можно было `DROP` старых партиций (или `ATTACH` в архив).
|
||||
|
||||
### 1.1. Текущее состояние (разведка 23.05 вечер)
|
||||
|
||||
**Все 7 таблиц — обычные heap, БЕЗ partition** (memory была неверна):
|
||||
|
||||
| # | Table | partition | hash-chain | RLS | size прод | rows прод |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 1 | `auth_log` | нет | ✅ | ✅ tenant_user (saas-admin исключён) | 112 kB | 32 |
|
||||
| 2 | `activity_log` | нет | ✅ | ✅ tenant_deal_created | 200 kB | 413 |
|
||||
| 3 | `tenant_operations_log` | нет | ✅ (inline) | ✅ | 104 kB | 9 |
|
||||
| 4 | `webhook_log` | нет | ❌ нет | ✅ | 32 kB | 0 |
|
||||
| 5 | `balance_transactions` | нет | ✅ | ✅ | 128 kB | 275 |
|
||||
| 6 | `pd_processing_log` | нет | ✅ | ✅ | 56 kB | 2 |
|
||||
| 7 | `saas_admin_audit_log` | нет | ✅ | ❌ saas-only | 48 kB | 0 |
|
||||
|
||||
**ИТОГО на проде: ~712 kB / 731 row.** Данных микро — миграция мгновенная.
|
||||
|
||||
### 1.2. Существующий tooling
|
||||
|
||||
- `App\Services\MonthlyPartitionManager::PARTITIONED_TABLES` — hardcoded whitelist на 2 таблицы (`deals`, `supplier_lead_costs`).
|
||||
- `App\Console\Commands\PartitionsCreateMonths` — daily cron, `--ahead=2` месяца вперёд. Идемпотентно.
|
||||
- `pg_partman` НЕ используется (был установлен на проде, но решено через app-cron).
|
||||
- На dev накоплено 102 partition children для `deals`/`supplier_lead_costs`.
|
||||
|
||||
### 1.3. Сложности
|
||||
|
||||
**S1. PRIMARY KEY.** PostgreSQL требует partition-key в PK. Сейчас PK=`(id)`. После — PK=`(id, created_at)` (или `received_at` для `webhook_log`).
|
||||
|
||||
**S2. FK на webhook_log.** Две таблицы ссылаются на `webhook_log.id`:
|
||||
|
||||
- `failed_webhook_jobs.webhook_log_id` (схема L1954) — 0 строк
|
||||
- `rejected_deals_log.webhook_log_id` (схема L1976) — 0 строк
|
||||
|
||||
В PG 16 FK на partitioned-таблицу с композитным PK невозможен **по одной колонке**. Варианты:
|
||||
|
||||
- (W1) **Удалить FK**, поддерживать целостность приложением (просто, минимально, 0 строк сейчас).
|
||||
- (W2) Добавить `received_at`-колонку в `failed_webhook_jobs`/`rejected_deals_log` + композитный FK на `(webhook_log_id, received_at)`. Сложнее, но FK сохраняется.
|
||||
- (W3) Не партиционировать `webhook_log` (нет hash-chain → нет аудиторной критичности; retention 90 дней через cleanup-job вместо partitioning).
|
||||
|
||||
**S3. hash-chain trigger `audit_chain_hash()`.** Использует `TG_TABLE_NAME` в SELECT prev-hash — после партиционирования `TG_TABLE_NAME` будет именем **партиции** (`auth_log_y2026_m05`), значит цепочка станет **per-partition**. Это **семантически согласуется** с уже выкаченным fix'ом hole #1 «per-RLS-scope hash-chain validation» (commits `378cfba4`/`a195611d`) — там тоже было «несколько scopes на таблицу». Валидатор `VerifyAuditChains` нужно адаптировать чтобы он сканировал партиции отдельно.
|
||||
|
||||
**S4. RLS на partitioned tables.** В PG 16 RLS-политики на parent НЕ наследуются партициями автоматически — нужно явно создавать на каждой партиции ИЛИ использовать parent-level policy через `ENABLE ROW LEVEL SECURITY` на parent + дублирование на партициях через cron. Это уже решено для `deals` (parent имеет RLS, partitions наследуют) — проверить в schema.sql точный паттерн.
|
||||
|
||||
**S5. Триггеры на partitioned tables.** `BEFORE INSERT` триггеры на parent **наследуются** в PG 16 (с версии 13+). Это работает.
|
||||
|
||||
**S6. Indexes.** Создаются на parent → на партициях создаются автоматически как локальные. Уникальные индексы должны включать partition-key.
|
||||
|
||||
---
|
||||
|
||||
## 2. Решение
|
||||
|
||||
### 2.1. Стратегия миграции
|
||||
|
||||
Поскольку нельзя `ALTER TABLE ... PARTITION BY`, миграция = **rewrite**:
|
||||
|
||||
1. Переименовать старую таблицу: `auth_log → auth_log_old`
|
||||
2. Создать новую partitioned: `CREATE TABLE auth_log (...) PARTITION BY RANGE (created_at)`
|
||||
3. Создать партиции для прошлого + текущего + 2 будущих месяца: `auth_log_y2026_m01 ... auth_log_y2026_m07`
|
||||
4. Скопировать данные: `INSERT INTO auth_log SELECT * FROM auth_log_old`
|
||||
5. Восстановить FK/RLS/triggers/indexes на новую parent
|
||||
6. `DROP TABLE auth_log_old` после verify
|
||||
|
||||
### 2.2. Scope
|
||||
|
||||
**Все 7 таблиц** — данных мало (731 row total), миграция дёшева. Если ждать пока вырастут — миграция станет дороже.
|
||||
|
||||
**FK на webhook_log:** выбор **W1 (удалить FK)** — минимально, 0 строк сейчас, app-уровень целостности достаточен (insert в `failed_webhook_jobs` всегда сопровождается известным `webhook_log_id`, который мы только что создали в той же транзакции).
|
||||
|
||||
### 2.3. Retention policy (новый cron-job)
|
||||
|
||||
Без `DROP` старых партиций цель «не растут вечно» не достигнута. Нужен второй cron:
|
||||
|
||||
- **`partitions:drop-expired`** — еженедельно (Sundays 03:00 МСК).
|
||||
- Per-table retention из `system_settings` (есть прецедент `webhook_log_retention_days=90`):
|
||||
- `auth_log_retention_months = 24` (152-ФЗ требует 6 лет для финансовых, но auth — security event, 2 года достаточно для расследования инцидентов)
|
||||
- `activity_log_retention_months = 36` (бизнес-журнал — 3 года для разборок)
|
||||
- `tenant_operations_log_retention_months = 24` (audit операций админа)
|
||||
- `webhook_log_retention_months = 3` (raw payloads с ПДн — короткий срок)
|
||||
- `balance_transactions_retention_months = 84` (7 лет — финансовые по ст.29 НК РФ)
|
||||
- `pd_processing_log_retention_months = 36` (152-ФЗ — 3 года минимум для PD-обработки)
|
||||
- `saas_admin_audit_log_retention_months = 84` (7 лет — действия админа = финансовые операции)
|
||||
|
||||
Эти значения — **дефолты**, изменяемы через `system_settings`. Drop делается атомарно (`DROP TABLE ... CASCADE` партиции — не trigger'ит каскадов на parent, RLS не теряется).
|
||||
|
||||
### 2.4. Hash-chain валидация после partitioning
|
||||
|
||||
`VerifyAuditChains` (hole #1) — обновить чтобы сканировал партиции отдельно:
|
||||
|
||||
- Старая логика: `SELECT MAX(id) FROM auth_log` + проверка SHA-256 цепочки от 1 до max.
|
||||
- Новая логика: для каждой партиции (получить список через `pg_partitions`) — проверить цепочку внутри неё. Разрыв в одной партиции = инцидент с указанием партиции.
|
||||
|
||||
Это семантически совместимо с уже выкаченным fix'ом per-RLS-scope (каждая партиция = свой scope, как saas-admin/tenant scope в auth_log).
|
||||
|
||||
---
|
||||
|
||||
## 3. Фазы выкатки
|
||||
|
||||
### Phase A — Разработка + dev-тесты (без прод)
|
||||
|
||||
A.1. Spec (этот документ)
|
||||
A.2. Plan (TDD-задачи)
|
||||
A.3. Extension `MonthlyPartitionManager::PARTITIONED_TABLES` (whitelist +7)
|
||||
A.4. Миграция rewrite для 7 таблиц (один файл, idempotent через `IF EXISTS`)
|
||||
A.5. Обновить `db/schema.sql` — заменить 7 объявлений
|
||||
A.6. Pest-тесты: partition создаётся / data копируется / RLS работает / FK удалён / hash-chain per-partition / `partitions:drop-expired` работает
|
||||
A.7. Adapt `VerifyAuditChains` (per-partition scan) + тесты
|
||||
A.8. Новая команда `partitions:drop-expired` + cron в `routes/console.php` + тесты
|
||||
A.9. cspell/pint/regression
|
||||
|
||||
### Phase B — Прод-выкатка (с явным approve заказчика на каждом шаге)
|
||||
|
||||
B.1. **Полный dump БД** (`pg_dump`) → `/home/ubuntu/deploy-backups/pre-partitioning-{ts}.dump`
|
||||
B.2. Verify dump: `pg_restore --list ... | wc -l` ≥ ожидаемое
|
||||
B.3. Применить миграцию (одна транзакция): `BEGIN; ... 7 rewrite steps ...; COMMIT;`
|
||||
B.4. Smoke: row count в каждой таблице соответствует pre-snapshot
|
||||
B.5. Запустить `partitions:create-months` — должны создаться партиции на 2 месяца вперёд
|
||||
B.6. Запустить `audit:verify-chains` — должна сработать новая per-partition логика, все chains intact
|
||||
B.7. Watch incidents_log 1 час — нет ли спайков от партиционирования
|
||||
|
||||
### Phase C — Documentation
|
||||
|
||||
C.1. Update ПИЛОТ.md §6 п.11 — добавить под-пункт #2
|
||||
C.2. Update ЭТАЛОН.md — schema v8.31
|
||||
C.3. Update memory `project_7holes_audit_followup` — закрыть #2
|
||||
C.4. Update master tracker overview — финал
|
||||
|
||||
---
|
||||
|
||||
## 4. Открытые вопросы для заказчика
|
||||
|
||||
**OQ1. Scope:** партиционировать **все 7** или меньше? Рекомендация — все 7 (данных мало, дёшево, лучше сейчас чем через год).
|
||||
|
||||
**OQ2. FK на webhook_log:** удалить FK (W1) или сохранить через композитный FK (W2)? Рекомендация — W1 (минимально, 0 рисков).
|
||||
|
||||
**OQ3. Retention defaults** (§2.3) — устраивают цифры?
|
||||
|
||||
- auth: 24 мес, activity: 36, tenant_ops: 24, webhook: 3, balance: 84, pd: 36, saas_admin: 84.
|
||||
|
||||
**OQ4. Cron расписание** для `partitions:drop-expired` — Sundays 03:00 МСК или другое?
|
||||
|
||||
**OQ5. Hash-chain семантика** — принять per-partition chain (рекомендация — да, совместимо с hole #1 per-scope логикой) или отказаться от partitioning hash-chain таблиц?
|
||||
|
||||
---
|
||||
|
||||
## 5. Риски
|
||||
|
||||
| Риск | Вероятность | Митигация |
|
||||
|---|---|---|
|
||||
| Потеря данных при rewrite | низкая | dump до миграции + транзакция + verify counts |
|
||||
| Hash-chain разрыв | средняя | per-partition scope (семантически OK); validator adapt |
|
||||
| RLS не наследуется | низкая | проверка паттерна на `deals` (уже работает) |
|
||||
| FK ломает существующий код | низкая | grep `webhook_log_id` использования; 0 строк сейчас |
|
||||
| Cron not running после deploy | низкая | smoke `schedule:list` + `scheduler_heartbeats` следит |
|
||||
| Долгая блокировка таблиц | очень низкая | данных <1MB, миграция <1сек |
|
||||
@@ -0,0 +1,208 @@
|
||||
# Observer parser — раскрытие skill/hook полей (schema v3)
|
||||
|
||||
**Дата:** 2026-05-23
|
||||
**Связано:** ADR-011 (brain governance), spec `2026-05-19-observer-factor-analysis-design.md`, Pravila §16, [[feedback_feature_via_writing_plans]]
|
||||
**Schema bump:** v2 → v3 (forward-only, прошлые v2 не правятся)
|
||||
|
||||
## Проблема
|
||||
|
||||
В `/brain-retro` факторном анализе самые интересные для дисциплины поля сейчас не раскрываются:
|
||||
|
||||
1. **Какой именно хук-скрипт сработал** — в `events[].hook_fired.counts` лежат только matcher-имена (`PreToolUse:Bash:8`). Имя файла-скрипта (`tools/subagent-prompt-prefix.mjs`, `tools/observer-stop-hook.mjs`, inline-хуки) не записывается, потому что Claude Code в transcript пишет `attachment.hookName = "PreToolUse:Bash"` (matcher), не имя файла. Один matcher может запускать несколько скриптов — все они невидимы по отдельности.
|
||||
|
||||
2. **Какой узел был бы рекомендован для `direct`-эпизода** — `node_chosen: 'direct'` для большинства эпизодов; читателю retro-сводки приходится отдельно сверяться с `missedActivations`, чтобы понять, был ли промах роутинга. В самом эпизоде явного сигнала нет.
|
||||
|
||||
Заказчик: «допили парсер — это отдельная задача» (23.05.2026, после A1/A2/B1/D1 retro-кандидатов).
|
||||
|
||||
## Решение
|
||||
|
||||
Три pure-модуля + минимальное расширение парсера. Forward-only — прошлые v2 эпизоды остаются как есть; analyzer фильтр расширяется до `schema_version >= 2`.
|
||||
|
||||
### Компоненты
|
||||
|
||||
**`tools/observer-hook-resolver.mjs`** (новый, ~80 LoC, pure)
|
||||
|
||||
```js
|
||||
export function buildHookMap({ projectSettings, userSettings } = {})
|
||||
// Map<matcher, string[]>
|
||||
// matcher: "PreToolUse:Bash" | "UserPromptSubmit" | "SessionStart:startup" | ...
|
||||
// value: ["tools/observer-stop-hook.mjs", "inline:claude-md-guard-7f3a", ...]
|
||||
|
||||
export function resolveScriptCounts(matcherCounts, hookMap)
|
||||
// { "tools/observer-stop-hook.mjs": 1, "inline:claude-md-guard-7f3a": 8, ... }
|
||||
```
|
||||
|
||||
Чтение `.claude/settings.json` (project) + `~/.claude/settings.json` (user) через `readFileSync` + `JSON.parse` в try/catch. Битый/отсутствующий файл → пустой map (parser fallback на matcher-only). Кэш в module-scope (per-process).
|
||||
|
||||
**Извлечение имени скрипта из `command` string** (приоритет сверху):
|
||||
|
||||
1. Regex `(?:^|[\s"'])(tools\/[\w-]+\.(?:mjs|py|sh))` → имя файла.
|
||||
2. Regex `(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)` → имя npm-пакета.
|
||||
3. Fallback `inline:<sha256(normalize(command)).slice(0,16)>` — стабильный для inline-хуков. `normalize(s)` = strip surrounding whitespace + collapse internal whitespace runs до одного пробела. Без lowercase (имена скриптов case-sensitive в Windows-окружении).
|
||||
|
||||
Если на один matcher навешано N скриптов — все возвращаются. Counts удваиваются: matcher `PreToolUse:Bash:8` с двумя скриптами → каждый скрипт получает счёт 8 (фактическое поведение — каждый скрипт исполняется на каждый matcher hit).
|
||||
|
||||
**`tools/observer-recommended-node.mjs`** (новый, ~30 LoC, pure)
|
||||
|
||||
```js
|
||||
export function recommendNode(taskClassification, classificationMap, dormancy = {})
|
||||
// → "#19" | null (Tooling Прил.Н node ID, точно как в classification-map)
|
||||
```
|
||||
|
||||
Источник правил — существующий `tools/observer-classification-map.json` (уже используется в `missed-activations.mjs`). Возвращает первый live (non-dormant) узел из `map[classification]` — формат **точно как в map** (`"#19"`, `"#43"`, и т.д., Tooling ID). Dormancy фильтрует DEFERRED через `dormancy[id] === false` (буквально false — паттерн из `missed-activations.mjs`, `tools/.node-dormancy.json` нормализуется `extract-node-dormancy.mjs`).
|
||||
|
||||
**`tools/observer-transcript-parser.mjs`** (расширение, ~15 LoC delta)
|
||||
|
||||
```js
|
||||
// extractProcessEvents — после построения hookCounts:
|
||||
const scriptCounts = resolveScriptCounts(hookCounts, getHookMap());
|
||||
events.push({
|
||||
kind: 'hook_fired',
|
||||
counts: hookCounts, // ← старое поле, сохраняется для backward-compat
|
||||
scripts: scriptCounts, // ← new
|
||||
errors: hookErrors,
|
||||
});
|
||||
|
||||
// parseTranscript — primary_rationale:
|
||||
recommended_node: skills.length === 0
|
||||
? recommendNode(classifyTask(prompt), classificationMap, dormancy)
|
||||
: null,
|
||||
```
|
||||
|
||||
Stop-хук (`observer-stop-hook.mjs`) не трогаем — он только вызывает parser.
|
||||
|
||||
### Schema v3
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema_version": 3,
|
||||
...
|
||||
"primary_rationale": {
|
||||
"step": 1,
|
||||
"node_chosen": "direct",
|
||||
"recommended_node": "#19", // ← new, Tooling node ID или null (если skill использован / нет рекомендации / все рекомендованные dormant)
|
||||
...
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"kind": "hook_fired",
|
||||
"counts": { "PreToolUse:Bash": 8, "PostToolUse:Bash": 4, ... },
|
||||
"scripts": { "tools/subagent-prompt-prefix.mjs": 1, "inline:claude-md-guard-7f3a": 8, ... },
|
||||
"errors": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Analyzer
|
||||
|
||||
**`tools/brain-retro-analyzer.mjs`:**
|
||||
|
||||
- Фильтр строка 202: `e.schema_version === 2` → `e.schema_version >= 2`. v3 без `recommended_node` ≡ v2.
|
||||
- `FACTOR_FNS` +1 ось: `recommended_node_for_direct: (e) => e.primary_rationale?.recommended_node ?? 'none'`. Эпизоды v2 автоматом попадают в bucket `'none'` — корректно.
|
||||
|
||||
NB: `missed-activations.mjs` сейчас фильтрует `e.schema_version !== 2` (строка 22) — поднять до `< 2`, чтобы v3 тоже попадал в детектор.
|
||||
|
||||
### Brain-retro template
|
||||
|
||||
**`.claude/skills/brain-retro/references/aggregation-template.md`:**
|
||||
|
||||
- Новая секция «Hook script breakdown» — топ-10 скриптов с count'ами + выделение discipline-enforcing (skill-discipline / economy-mode / subagent-prefix / claude-md-guard).
|
||||
- Расширить «Missed Activations» — теперь рядом с агрегированным `missedActivations.byNode` сводка из самих эпизодов: `direct + recommended_node != null` → явный сигнал в каждом таком эпизоде.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Stop-hook → parser.parseTranscript(transcriptText)
|
||||
├─ collectToolUse → skills[], counts, errors
|
||||
├─ extractProcessEvents
|
||||
│ ├─ hookCounts (matcher) ← из attachment.hookName
|
||||
│ ├─ resolveScriptCounts(hookCounts, hookResolver.buildHookMap())
|
||||
│ └─ event hook_fired = { counts, scripts, errors }
|
||||
└─ primary_rationale
|
||||
└─ recommended_node = skills.length === 0
|
||||
? recommendNode(classifyTask(prompt), classificationMap, dormancy)
|
||||
: null
|
||||
→ episode v3 → append docs/observer/episodes-YYYY-MM.jsonl
|
||||
|
||||
/brain-retro → analyzer.analyze(episodes, { classificationMap, dormancy })
|
||||
├─ filter schema_version >= 2
|
||||
├─ buildFactorMatrix + recommended_node_for_direct axis
|
||||
└─ → aggregation-template renders Hook script breakdown
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
| Случай | Поведение |
|
||||
|---|---|
|
||||
| `.claude/settings.json` отсутствует / битый JSON | resolver возвращает пустой map, parser fallback на matcher-only counts, `scripts: {}` |
|
||||
| Regex не зацепил имя скрипта в command | fallback `inline:<sha-16>`, стабильный к форматированию |
|
||||
| `classification-map.json` отсутствует / пустой массив для классификации | `recommendNode` возвращает null; parser fallback `recommended_node: null` |
|
||||
| Classification не в map (`other`, `question`, `memory-sync`) или пустой массив | `recommendNode` возвращает null — корректное «нет рекомендации» |
|
||||
| Все рекомендованные узлы dormant | `recommendNode` возвращает null |
|
||||
| v2 эпизод без `scripts`/`recommended_node` | analyzer считает как v2 (bucket `'none'` для новой оси) — backward-compat сохранён |
|
||||
|
||||
## Security Guidance #40
|
||||
|
||||
- Pure parsing, no `exec`/`execSync` (consistent с `observer-transcript-parser.mjs:14` shebang).
|
||||
- Resolver читает только `.claude/settings.json` (+ `~/.claude/settings.json`) — known paths.
|
||||
- Регекс на `command` string — без eval.
|
||||
- SHA-fallback — `node:crypto` `createHash('sha256')`, no shell.
|
||||
|
||||
## Testing (TDD)
|
||||
|
||||
Прецедент: все `observer-*-detector.mjs` имеют парный `.test.mjs` через `vitest` (config `app/vitest.config.tools.mjs`, скрипт `npm run test:tools`).
|
||||
|
||||
**`observer-hook-resolver.test.mjs`:**
|
||||
|
||||
- parse project-only / user-only / merged settings.json
|
||||
- matcher с одним хуком / с несколькими (counts удваиваются)
|
||||
- command как `node tools/X.mjs` / `node -e "..."` / `npx -y pkg` / неизвестный → fallback
|
||||
- битый/отсутствующий settings.json → пустой map, без throw
|
||||
- `resolveScriptCounts({}, map)` → `{}`
|
||||
|
||||
**`observer-recommended-node.test.mjs`:**
|
||||
|
||||
- классификация в map → первый live ID (например `"#19"` для `feature`)
|
||||
- классификация в map, первый узел dormant → следующий live или null
|
||||
- классификация не в map (`other`) → null
|
||||
- классификация в map с пустым массивом (`question`, `memory-sync`) → null
|
||||
|
||||
**`observer-transcript-parser.test.mjs` (+3 case):**
|
||||
|
||||
- turn с hook-attachments → `hook_fired.scripts` непустой, `hook_fired.counts` сохранён
|
||||
- direct-эпизод с feature-prompt → `recommended_node === '#19'`
|
||||
- skill-эпизод → `recommended_node === null`
|
||||
|
||||
**`brain-retro-analyzer.test.mjs` (+1 case):**
|
||||
|
||||
- mix v2 + v3 эпизодов → оба считаются
|
||||
|
||||
**Regression smoke:**
|
||||
|
||||
- Прогнать parser на живом `docs/observer/episodes-2026-05.jsonl` через CLI — 0 throw, все эпизоды parse OK.
|
||||
|
||||
## Риски и mitigation
|
||||
|
||||
| Риск | Mitigation |
|
||||
|---|---|
|
||||
| settings.json меняется между эпизодами → resolver-кэш стейл | Кэш per-process; parser/Stop-хук однопроцессны на эпизод — стейл невозможен. |
|
||||
| `inline:<sha>` шумит при частом изменении inline-хука | sha от нормализованной (strip whitespace) команды — стабильна к форматированию. |
|
||||
| Conflict с параллельной правкой `brain-retro-analyzer.mjs` (Pravila §15) | Pre-flight `git fetch && git log HEAD..origin/main` перед началом плана. |
|
||||
| `recommended_skill` воспринимается как hard-rule | В spec явно: рекомендация ≠ обязанность; missedActivations остаются сигналом, не блоком (Pravila §16.4 v1.36 условное правило). |
|
||||
|
||||
## Объём (≈5 TDD commits)
|
||||
|
||||
1. `observer-hook-resolver.mjs` + tests
|
||||
2. `observer-recommended-node.mjs` + tests
|
||||
3. parser extension + tests + smoke на живом JSONL
|
||||
4. analyzer filter `>= 2` + новая factor-ось + missed-activations filter `< 2` + tests
|
||||
5. brain-retro template + retro-skill SKILL.md note + cross-ref note в factor-analysis spec
|
||||
|
||||
## Не делаем (out of scope)
|
||||
|
||||
- Retrofill прошлых v2 эпизодов (заказчик: forward-only).
|
||||
- Полный per-script timing/duration (Claude Code stdout его не пишет).
|
||||
- Explicit discipline-hook booleans (`economy_parser: true` и т.д.) — derived от `scripts` для read, дублирование не нужно.
|
||||
- Правка Stop-хука / observer-of-observer / coverage-checker — никаких изменений infrastructure.
|
||||
- Bump Pravila/CLAUDE.md/PSR_v1/Tooling — это инструментальное расширение в `tools/`, нормативка не меняется. Только бамп `schema_version` в spec'е factor-analysis (cross-ref).
|
||||
@@ -0,0 +1,310 @@
|
||||
# Router discipline overhaul — машиночитаемый реестр + hard-enforcement
|
||||
|
||||
**Дата:** 2026-05-23
|
||||
**Связано:** ADR-011 (brain governance), spec `2026-05-19-observer-factor-analysis-design.md`, Pravila §12/§14/§15/§16, PSR_v1 R0-R15, [[feedback_superpowers_hard_rule]], [[feedback_feature_via_writing_plans]]
|
||||
**Brainstorming:** этот документ — итог brainstorming-диалога 23.05.2026 (13:00-14:00 MSK). 4 уточняющих вопроса, 3 варианта approach, выбран B (поэтапный rollout).
|
||||
|
||||
## Проблема
|
||||
|
||||
Факторный анализ 134 эпизодов мая 2026 показал систематический провал дисциплины роутера:
|
||||
|
||||
1. **73% эпизодов идут `direct`** — без вызова какого-либо skill/subagent.
|
||||
2. **0% триггер-матча на feature/planning/memory-sync** — на этих типах задач я даже не сканирую реестр узлов.
|
||||
3. **50% триггер-матча на analysis, 33% на bugfix** — способность находить узлы есть, но включается только на «явно профильных» задачах.
|
||||
4. **79% эпизодов с пометкой `regulated` не применяют ни одной границы из ADR** — метка формальная.
|
||||
5. **Hard-floor сработал только 14 раз из 134** (10%), и всегда только Pravila §12. §14 (Queen) и §15 (parallel sessions) — 0 следов в журнале.
|
||||
6. **Парсер `candidates_considered` пишет туда булиты моих ответов** вместо имён узлов реестра — невозможно проверить, реально ли я смотрел альтернативы.
|
||||
7. **Правила децентрализованы** — Pravila §12-15, PSR_v1 R0-R15, Tooling §4.X (9-attribute blocks на 83 узла), routing-off-phase.md, ADR-* — итого 5 источников. Чтобы выбрать узел, нужно прыгать по документам.
|
||||
|
||||
Текущая процедура `docs/router-procedure.md` v1.4 явно говорит «No forced-choice gate. Nodes that don't match triggers are silently skipped». Это намеренный дизайн, который выбирался при ADR-011 — но при текущем уровне дисциплины (27%) он не работает: я не сканирую реестр на болевых типах задач.
|
||||
|
||||
Заказчик 23.05.2026: «как заставить тебя не делать без скилов и плагинов?» → выбран **hard-enforcement**: точность на каждой задаче + запрет direct когда подходящий навык существует.
|
||||
|
||||
## Цель и границы
|
||||
|
||||
**Цель:**
|
||||
|
||||
1. На каждой классифицированной задаче берётся **правильный** навык (узел реестра).
|
||||
2. **Запрет `direct`** когда подходящий навык существует.
|
||||
|
||||
**Исключение:** только сверх-мелкие правки (micro). Любая другая работа — через навык, если реестр предлагает подходящий узел.
|
||||
|
||||
**Что считается micro** (черновик, уточняется в этапе 3):
|
||||
|
||||
- ≤2 файла затронуто
|
||||
- ≤20 строк изменено
|
||||
- Тип ∈ {опечатка, переименование, удаление мёртвого кода, правка одного значения константы, форматирование}
|
||||
- Классификатор явно метит `task_type=micro` (см. этап 3)
|
||||
|
||||
**Что НЕ входит в scope:**
|
||||
|
||||
- Изменение бизнес-логики продукта Лидерра.
|
||||
- Новые навыки/инструменты в реестре сверх существующих 83.
|
||||
- Замена `economy mode` хук-архитектуры (она остаётся, мы её дополняем).
|
||||
- Изменение Pravila §16 наблюдателя (только потребляем его данные).
|
||||
|
||||
## Архитектура
|
||||
|
||||
Четыре независимых слоя:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Слой 4: НОРМАТИВКА (Pravila/PSR_v1/Tooling) │
|
||||
│ — сокращена до cross-refs на реестр + уникальное │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↑ читают/делегируют
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Слой 1: РЕЕСТР (registry.yaml, machine-readable)│
|
||||
│ — единственный источник истины «task → node» │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↑ читают
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Слой 3: КЛАССИФИКАТОР + PreToolUse hook │
|
||||
│ — regex pre-screen → Sonnet escalation → block │
|
||||
└─────────────────────────────────────────────────┘
|
||||
↑ пишут/читают данные
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Слой 2: ИЗМЕРЕНИЯ (parser fix + STATUS.md) │
|
||||
│ — baseline ДО enforcement, тренд ПОСЛЕ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Этапы
|
||||
|
||||
### Этап 1 — Справочник (фундамент)
|
||||
|
||||
**Цель:** машиночитаемый реестр всех 83 узлов в одном файле + auto-render существующих Markdown-страниц.
|
||||
|
||||
**Артефакты:**
|
||||
|
||||
- **`docs/registry/nodes.yaml`** — YAML-реестр узлов. Схема:
|
||||
|
||||
```yaml
|
||||
nodes:
|
||||
- id: "#19"
|
||||
name: "Superpowers v5.1.0"
|
||||
slug: "superpowers"
|
||||
category: "phase-2"
|
||||
subcategory: null
|
||||
triggers:
|
||||
- {keyword: "tdd", weight: 1.0}
|
||||
- {classification: "feature", weight: 1.0}
|
||||
- {classification: "planning", weight: 1.0}
|
||||
# ... все триггеры из Tooling §4.X
|
||||
boundaries:
|
||||
- {adr: "ADR-011", role: "hard-floor source"}
|
||||
- {pair: "#30", relation: "paired stack"}
|
||||
chain_membership: ["L1", "L8"]
|
||||
status: "active" # active | dormant | deferred
|
||||
dormancy_reason: null
|
||||
attributes:
|
||||
# все 9 атрибутов из Tooling Прил. Н §0.1
|
||||
chains:
|
||||
L1:
|
||||
name: "Brainstorming chain"
|
||||
sequence: ["superpowers:brainstorming", "superpowers:writing-plans", "superpowers:executing-plans"]
|
||||
triggers: [...]
|
||||
# ... L1-L16
|
||||
```
|
||||
|
||||
- **`docs/registry/schema.json`** — JSON Schema валидация YAML.
|
||||
- **`tools/registry-render.mjs`** — pure script, читает `nodes.yaml` → рендерит:
|
||||
- `docs/Tooling_v8_3.md` §4.X (auto-region между маркерами `<!-- auto:registry -->...<!-- /auto:registry -->`)
|
||||
- `docs/routing-off-phase.md` §3 routing-таблица + §4 chains
|
||||
- `docs/registry/index.md` — индекс для людей
|
||||
- **`tools/registry-load.mjs`** — pure module, экспортирует `loadRegistry()` → `{ nodes, chains, indexByTrigger, indexById }` для использования в хуках и анализаторе.
|
||||
- **`tools/registry-load.test.mjs`** — unit-тесты (≥10): загрузка, ошибка YAML, поиск по триггеру, поиск по id, dormant exclusion.
|
||||
- **lefthook job** `registry-render-check` (pre-commit, warn-only first week, then blocking): запускает render, проверяет что Markdown-регионы не дрейфят от реестра.
|
||||
|
||||
**Источник данных:** парсим текущий `docs/Tooling_v8_3.md` §4.X (83 узла × 9 атрибутов = ~750 полей). Это ручной перенос с верификацией. Risk: парсинг Tooling может пропустить нюансы.
|
||||
|
||||
**Что НЕ меняется в этом этапе:**
|
||||
|
||||
- Pravila/PSR_v1 — не трогаем.
|
||||
- Хуки — не трогаем (реестр пока никто не enforce'ит).
|
||||
- Поведение Claude — не меняется.
|
||||
|
||||
**Definition of done:**
|
||||
|
||||
- 83 узла в `nodes.yaml`, валидация JSON Schema проходит.
|
||||
- `tools/registry-render.mjs` рендерит auto-region в Tooling.md и routing-off-phase.md, diff с текущими файлами = 0 (т.е. рендер совместим).
|
||||
- ≥10 unit-тестов в `registry-load.test.mjs` GREEN.
|
||||
- Документация `docs/registry/README.md` — как добавлять/менять узел.
|
||||
- Работа в feature-branch + PR в `main`; Pravila §15.2 pre-flight sync обязателен (Tooling §4.X — в списке 8 нормативных файлов).
|
||||
|
||||
**Точка остановки:** ты говоришь «продолжаем» → этап 2.
|
||||
|
||||
### Этап 2 — Измерения (baseline до enforcement)
|
||||
|
||||
**Цель:** добавить метрики разбивки дисциплины по типам задач, получить **honest «точку До»** дисциплины.
|
||||
|
||||
**Что УЖЕ сделано параллельной сессией 23.05 (коммиты `4665c537`, `6192d395`, `6a9df652` — spec `2026-05-23-observer-parser-skill-hook-expand-design.md`):**
|
||||
|
||||
- ✅ Fix `tools/observer-transcript-parser.mjs` — `candidates_considered` whitelist filter (`tools/observer-known-nodes.txt` + `observer-chain-map.json`); проза/процедуры/код отсекаются. Forward-only.
|
||||
- ✅ Schema v3 — `schema_version` 2 → 3. Старые v2-эпизоды не правим (append-only).
|
||||
- ✅ `primary_rationale.recommended_node` — поле есть в v3, заполняется из `observer-classification-map.json` (этап 2 переключит на реестр).
|
||||
- ✅ `events[].hook_fired.scripts` — reverse-lookup имени скрипта из `.claude/settings.json` + user.
|
||||
- ✅ `tools/brain-retro-analyzer.mjs` accepts schema ≥2 (v2+v3 mix); добавлена `recommended_node_for_direct` факторная ось.
|
||||
|
||||
**Что осталось делать в этапе 2:**
|
||||
|
||||
- **`tools/brain-retro-analyzer.mjs`** — новые срезы:
|
||||
- `disciplinePercentByClassification` — % эпизодов с матченным триггером, разбивка по `task_classification`.
|
||||
- `routerStepReached` — на каком шаге роутера эпизод действительно остановился (если step=1 для всех — это баг парсера, отметить).
|
||||
- `boundariesAppliedRate` — % эпизодов с непустым `boundaries_applied`.
|
||||
- **`tools/missed-activations.mjs`** — переключить с `observer-classification-map.json` на реестр из этапа 1 (`docs/registry/nodes.yaml`). Маппинг classification → recommended_nodes теперь автоматический через `nodes[i].triggers[].classification`.
|
||||
- **`docs/observer/STATUS.md` generator** — добавить блок «Метрики дисциплины»:
|
||||
|
||||
```
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| feature | 5 | 0% | 0% |
|
||||
| planning | 3 | 0% | 0% |
|
||||
| bugfix | 6 | 33% | 33% |
|
||||
| analysis | 8 | 50% | 50% |
|
||||
```
|
||||
|
||||
- **Baseline snapshot** в `docs/observer/baselines/2026-XX-XX-pre-enforcement.md` — фиксируем цифры «до» (% дисциплины, missed activations, % regulated, % границ применено), чтобы потом сравнить.
|
||||
|
||||
**Definition of done:**
|
||||
|
||||
- STATUS.md показывает разбивку дисциплины по типам задач.
|
||||
- Baseline snapshot закоммичен.
|
||||
- Существующие тесты `brain-retro-analyzer.test.mjs` + новые срезы (`disciplinePercentByClassification`/`routerStepReached`/`boundariesAppliedRate`) — GREEN.
|
||||
- `missed-activations.mjs` читает реестр из этапа 1; результат на тех же данных не должен сильно дрейфить от текущего (sanity check). Тесты GREEN.
|
||||
|
||||
**Точка остановки:** ты видишь цифры «27% дисциплины, 0% на feature», говоришь «продолжаем» → этап 3.
|
||||
|
||||
### Этап 3 — Принуждение (где включается enforcement)
|
||||
|
||||
**Цель:** хук, который **физически блокирует** Edit/Write/Bash на не-micro классифицированной задаче, если skill ещё не вызван.
|
||||
|
||||
**Артефакты:**
|
||||
|
||||
- **`tools/router-classifier.mjs`** — pure module, гибрид:
|
||||
- **Layer 1 (regex pre-screen, ~10 ms):** ключевые слова RU+EN (`фича`, `feature`, `план`, `plan`, `дебаг`, `bug`, `опечатка`, `typo`, `переименуй`, `rename`, …) → классификация + micro-flag. Покрывает ~70% промптов.
|
||||
- **Layer 2 (Sonnet escalation, ~300-500 ms, $0.001-0.005/call):** если regex не уверен (multiple matches / no match / ambiguous), отправить промпт + текущую структуру задачи в Sonnet с фиксированным prompt-template, получить `{task_type, micro: bool, recommended_node: id|null, confidence: 0-1}`. Кеш per-prompt-hash в memory чтобы не повторять.
|
||||
- **Output:** `{classification, micro, recommended_node, source: 'regex'|'llm'|'cache'}`.
|
||||
- **`tools/router-prehook.mjs`** — UserPromptSubmit hook:
|
||||
- Вызывает classifier.
|
||||
- Пишет результат в `~/.claude/runtime/router-state-<session>.json` (для текущего хода).
|
||||
- Если `micro: false` AND `recommended_node != null` AND `classification` ∈ §12.2 list → выставляет флаг `enforcement_required: true`.
|
||||
- **`tools/router-tool-gate.mjs`** — PreToolUse hook:
|
||||
- Если `enforcement_required` AND tool ∈ {Edit, Write, Bash (kроме read-only ls/cat/git status)} AND `skill_invoked_this_turn == false` → **`decision: block`** с сообщением «Эта задача классифицирована как `feature` (например). Реестр рекомендует `superpowers:writing-plans`. Вызови skill ПЕРВЫМ, либо начни ответ с `<!-- routing: ... -->` тэга с обоснованием.»
|
||||
- Если есть routing-tag с явным `direct_justified=true` и обоснованием — пропускает (escape hatch для редких случаев когда классификатор ошибся).
|
||||
- **`tools/router-stop-gate.mjs`** — Stop hook (расширение существующего routing-gate):
|
||||
- Если enforcement сработал и был обойдён через routing-tag → пишет это в эпизод как `decision_provenance: user_directed_method` с пометкой «manual override».
|
||||
- **Конфигурация** `.claude/settings.json`:
|
||||
- Регистрация трёх новых хуков (UserPromptSubmit / PreToolUse / Stop расширение).
|
||||
- Permissions: запретить Edit/Write/Bash до Skill через `allow`-флаг? Нет — мы это делаем через хук, чтобы было видно решение.
|
||||
|
||||
**Тестирование:**
|
||||
|
||||
- Записать 20 ручных тестовых промптов (5 feature, 5 micro, 5 planning, 5 question) → прогнать через classifier, проверить.
|
||||
- Live-тест на одной сессии — посмотреть FP/FN, итерация regex'а.
|
||||
- Откат за 5 минут: убрать хуки из `settings.json` (no code change needed for rollback).
|
||||
|
||||
**Definition of done:**
|
||||
|
||||
- Classifier валидируется ≥80% точности на тестовом наборе.
|
||||
- PreToolUse hook верно блокирует на feature-promt без skill (verified live).
|
||||
- Routing-tag escape hatch работает (verified live с явным `direct_justified=true`).
|
||||
- В STATUS.md метрика «% дисциплины» через неделю показывает рост (≥60% от baseline).
|
||||
- Стоимость классификатора в Anthropic dashboard: ≤$15/мес сверх текущего.
|
||||
|
||||
**Точка остановки:** ты смотришь на цифру «дисциплина 27% → 75%», и либо продолжаем → этап 4, либо откатываем (если enforcement слишком жёсткий, FP > 20%).
|
||||
|
||||
### Этап 4 — Уборка правил (нормативка)
|
||||
|
||||
**Цель:** сократить Pravila/PSR_v1/Tooling до cross-refs + unique justifications. Все «routing-relevant» куски делегируются в реестр.
|
||||
|
||||
**Артефакты:**
|
||||
|
||||
- **Pravila §12-§15** — оставить декларацию hard-rule + cross-ref на реестр. Списки 14 типов задач (§12.2) перенести в реестр как trigger-теги узлов.
|
||||
- **PSR_v1 R0-R15** — UI-stack apparatus оставить (это про **как** использовать Vue+Vuetify, не про роутинг). R10/R14/R15 (routing-relevant) сократить до cross-refs на реестр.
|
||||
- **Tooling §4.X** — становится auto-region из `registry-render.mjs`. Прозьба для людей: «не правьте напрямую, правьте `docs/registry/nodes.yaml`».
|
||||
- **`docs/routing-off-phase.md`** — становится auto-region (routing-таблица + L1-L16 chains).
|
||||
- **ADR-016 (новый)** — фиксирует архитектурный сдвиг «реестр как single source», описывает alternatives rejected (зачем не Big Bang, не keyword-only, не LLM-only).
|
||||
- **CLAUDE.md §0** — обновить cross-refs на новые версии Pravila/PSR_v1/Tooling.
|
||||
- **`docs/router-procedure.md` v2.0** — переписать процедуру: шаг 3 теперь «classifier выдаёт recommended_node» вместо «scan Tooling §4.X manually».
|
||||
|
||||
**Definition of done:**
|
||||
|
||||
- Все cross-refs валидны (lychee 0 broken).
|
||||
- `cross-ref-checker` C2 контролёр — 0 drift.
|
||||
- `l1-watcher` C1 контролёр — 0 drift.
|
||||
- Pravila/PSR_v1/Tooling diff vs предыдущая версия — сокращение ≥30% строк за счёт удаления дублей.
|
||||
- ADR-016 committed.
|
||||
|
||||
**Точка остановки:** этап 4 — последний. После него — финальный brain-retro «cycle closed», measure long-term effect.
|
||||
|
||||
## Continuity механизм (как не забыть)
|
||||
|
||||
Тройная страховка:
|
||||
|
||||
1. **STATUS.md раздел «Активные многоэтапные проекты»** — в конце каждого этапа я обязан добавить блок:
|
||||
|
||||
```
|
||||
## Активные проекты
|
||||
- **Router discipline overhaul** (spec 2026-05-23-router-discipline-overhaul-design.md)
|
||||
- Этап 1 ✅ закрыт 2026-XX-XX (коммиты abc..def)
|
||||
- Этап 2 ⏸ ждёт «продолжаем» от заказчика (с YYYY-MM-DD)
|
||||
- Следующий: `docs/superpowers/plans/2026-XX-XX-router-overhaul-stage-2.md`
|
||||
```
|
||||
|
||||
STATUS.md загружается в системный промпт → я вижу при старте каждой сессии.
|
||||
|
||||
2. **memory-файл `project_router_overhaul.md`** в `memory/` — current stage, blockers, last action timestamp, next step pointer. MEMORY.md индекс ссылается на этот файл.
|
||||
|
||||
3. **brain-retro еженедельный анализ** — отдельный срез «open multi-stage projects, last activity»; если >7 дней молчания на этапе — surface в C5 ⚠️.
|
||||
|
||||
В конце каждого этапа я обязан **спросить заказчика** напрямую: «Этап N закрыт, цифры такие-то. Запускаем N+1 или пауза?» Это часть `superpowers:finishing-a-development-branch`.
|
||||
|
||||
## Открытые вопросы и риски
|
||||
|
||||
### Открытые вопросы (для решения по ходу)
|
||||
|
||||
1. **OQ-1.** Threshold классификатора Layer 1 confidence для escalation в Layer 2 — будет calibrated на тестовом наборе в этапе 3.
|
||||
2. **OQ-2.** Порог micro по строкам — 20 строк это первоначальный guess; возможно нужен 50 или зависит от типа файла. Определим эмпирически в этапе 3.
|
||||
3. **OQ-3.** ADR-границы как переносить в YAML? Простой список `boundaries: [{adr, rule}]` или richer структура? Решим в этапе 1 после прототипа.
|
||||
4. **OQ-4.** Кеш классификатора — per-session или persistent (across-session)? Влияет на стоимость и точность при повторных похожих промптах.
|
||||
|
||||
### Риски
|
||||
|
||||
- **R-1. Классификатор русскоязычный — Sonnet может ошибаться на жаргоне.** Mitigation: regex pre-screen покрывает 70% явных случаев, Sonnet получает richer context (recent history + task structure).
|
||||
- **R-2. False positive enforcement блокирует тривиальную задачу.** Mitigation: routing-tag escape hatch, метрика FP rate в STATUS.md, weekly review.
|
||||
- **R-3. Парсинг Tooling §4.X в YAML пропустит атрибуты.** Mitigation: render-check сравнивает rendered Markdown с originalом, diff = 0.
|
||||
- **R-4. Параллельные сессии правят нормативку → collision в этапе 4.** Mitigation: Pravila §15.2 pre-flight sync обязателен на каждом коммите этапа 4.
|
||||
- **R-5. Откат хука в этапе 3 не сработает на уже запущенной сессии.** Mitigation: documenting force-restart procedure, plus per-session enforcement_required flag (sessions started before flag — exempt).
|
||||
- **R-6. Стоимость Sonnet escalation выйдет за бюджет.** Mitigation: hard cap в `router-classifier.mjs` (≤200 Sonnet calls/день, при превышении — fallback на regex-only, метрика в STATUS.md).
|
||||
- **R-7. Я сам не вспомню запустить этап 2 через неделю.** Mitigation: тройная continuity-страховка (STATUS.md + memory + brain-retro), плюс явный prompt в конце этапа 1 «Готов запускать этап 2?».
|
||||
|
||||
## Что НЕ входит в scope
|
||||
|
||||
- **Замена observer/JSONL архитектуры** — она работает, потребляем её.
|
||||
- **Замена economy mode хуков** — они остаются как есть, мы добавляем рядом.
|
||||
- **Многоязычность классификатора** — RU+EN достаточно (нет других языков в журнале).
|
||||
- **GUI для редактирования реестра** — правка YAML руками.
|
||||
- **Поддержка Claude Code старых версий** (≤2.0) — мы на 2.x.
|
||||
- **Real-time дашборд** — STATUS.md + brain-retro достаточно.
|
||||
|
||||
## Acceptance criteria для всего overhaul'а (после этапа 4)
|
||||
|
||||
- Дисциплина (% эпизодов с матченным триггером на классифицированных задачах): **≥75%** (baseline 27%).
|
||||
- Missed activations: **≤5/неделю** (baseline 40/месяц = ~10/неделю с шумом, ~3/неделю без шума).
|
||||
- % feature/planning без skill: **≤10%** (baseline 80%).
|
||||
- Стоимость дополнительная: **≤$20/мес**.
|
||||
- Откатываемость: **полный rollback ≤30 минут** (через revert коммитов + удаление хуков из settings.json).
|
||||
- Документация: новичок может прочитать `docs/registry/README.md` и понять как добавить узел за 15 минут.
|
||||
|
||||
## Self-review (после написания, перед user review)
|
||||
|
||||
- ✅ Placeholders: нет «TBD», все секции заполнены.
|
||||
- ✅ Внутренние противоречия: четыре этапа не пересекаются, точки остановки явные.
|
||||
- ✅ Scope: подходит для 4 sub-plans (не один). Декомпозиция в этапах явная.
|
||||
- ✅ Ambiguity: открытые вопросы выделены в OQ-1..4, к ним возвращаемся в соответствующих этапах. «Что считается micro» помечен как «уточняется в этапе 3».
|
||||
- ⚠️ Source of truth для счётчиков узлов (83) — берётся из Tooling Прил. Н §0 (канон, finding 3 SYSTEM-аудита 18.05.2026), не из CLAUDE.md.
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
После твоего ревью этого спека → `writing-plans` skill → план для этапа 1 (справочник). После закрытия этапа 1 — план этапа 2. И так далее.
|
||||
+24
-5
@@ -38,8 +38,11 @@ pre-commit:
|
||||
- ".claude/skills/ccpm/**"
|
||||
- ".claude/skills/data-scientist/**"
|
||||
- ".claude/skills/marketingskills/**"
|
||||
run: npx markdownlint-cli2 --fix {staged_files}
|
||||
stage_fixed: true
|
||||
run: node node_modules/markdownlint-cli2/markdownlint-cli2-bin.mjs --fix {staged_files}
|
||||
# stage_fixed убран 23.05.2026: на этой Windows-машине он триггерит
|
||||
# `git stash create` (прятать unstaged), который конфликтует за .git/index.lock
|
||||
# с родительским git commit → коммит виснет. Авто-fix всё равно правит файлы
|
||||
# в рабочей копии, но не авто-restage — git add вручную после правок.
|
||||
fail_text: |
|
||||
markdownlint нашёл проблемы, которые не исправляются автоматически.
|
||||
Запусти `npm run lint:md:fix` или поправь руками.
|
||||
@@ -55,7 +58,7 @@ pre-commit:
|
||||
- ".claude/skills/ccpm/**"
|
||||
- ".claude/skills/data-scientist/**"
|
||||
- ".claude/skills/marketingskills/**"
|
||||
run: npx cspell --no-progress --no-summary --no-gitignore {staged_files}
|
||||
run: node node_modules/cspell/bin.mjs --no-progress --no-summary --no-gitignore {staged_files}
|
||||
fail_text: |
|
||||
cspell нашёл слова, отсутствующие в словаре.
|
||||
Если это валидное слово проекта — добавь в cspell-words.txt.
|
||||
@@ -64,7 +67,7 @@ pre-commit:
|
||||
# 4. Stylelint — стиль CSS в HTML-прототипах
|
||||
- name: stylelint
|
||||
glob: "*.html"
|
||||
run: npx stylelint {staged_files}
|
||||
run: node node_modules/stylelint/bin/stylelint.mjs {staged_files}
|
||||
fail_text: |
|
||||
Stylelint нашёл проблемы в CSS прототипа.
|
||||
Запусти `npx stylelint --fix <file>` где возможно.
|
||||
@@ -74,7 +77,8 @@ pre-commit:
|
||||
glob: "app/**/*.php"
|
||||
root: "app/"
|
||||
run: php vendor/bin/pint {staged_files}
|
||||
stage_fixed: true
|
||||
# stage_fixed убран 23.05.2026 — см. комментарий у markdownlint-джоба
|
||||
# (git stash create ↔ index.lock конфликт на Windows).
|
||||
fail_text: |
|
||||
Pint не смог отформатировать какие-то файлы (синтаксическая ошибка PHP?).
|
||||
Запусти `cd app && composer pint` локально, посмотри вывод.
|
||||
@@ -220,6 +224,21 @@ pre-commit:
|
||||
observer-chain-map-checker: дрейф chain-map <-> routing-off-phase.md.
|
||||
Обновите tools/observer-chain-map.json под таблицу L1-LN.
|
||||
|
||||
# 17. registry-render-check — drift между docs/registry/nodes.yaml и
|
||||
# auto-region маркерами в docs/Tooling_v8_3.md / docs/routing-off-phase.md
|
||||
# (router overhaul этап 1, task 11). Warn-only первую неделю — `|| exit 0`
|
||||
# печатает WARN, но не блокирует коммит; после стабилизации убрать `|| ...`
|
||||
# и сделать blocking.
|
||||
- name: registry-render-check
|
||||
glob: "{docs/registry/nodes.yaml,docs/Tooling_v8_3.md,docs/routing-off-phase.md}"
|
||||
run: |
|
||||
if ! node tools/registry-render.mjs --check; then
|
||||
echo "[registry] WARN: rendered != файл. Запусти 'node tools/registry-render.mjs' и закоммить."
|
||||
fi
|
||||
fail_text: |
|
||||
registry-render-check: rendered output расходится с auto-region маркером.
|
||||
Запустите `node tools/registry-render.mjs` и закоммитьте Tooling/routing-off-phase.
|
||||
|
||||
# Post-commit: regenerate STATUS.md dashboard (informational, not gate)
|
||||
post-commit:
|
||||
parallel: false
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
# as part of a group (e.g. Trail of Bits Skills #39 = 8 sub-plugins).
|
||||
|
||||
frontend-design@claude-plugins-official=Frontend Design plugin
|
||||
# brand-voice — formalized под своим #76 (Tooling §4.51), но settings.json держит
|
||||
# его машинным ключом brand-voice@knowledge-work-plugins, а Tooling — человеческим
|
||||
# «brand-voice»; алиас мостит имя (как frontend-design выше). 2026-05-23.
|
||||
brand-voice@knowledge-work-plugins=brand-voice
|
||||
differential-review@trailofbits=Trail of Bits Skills
|
||||
audit-context-building@trailofbits=Trail of Bits Skills
|
||||
supply-chain-risk-auditor@trailofbits=Trail of Bits Skills
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"#31": false,
|
||||
"#32": false,
|
||||
"#33": false,
|
||||
"#34": false,
|
||||
"#34": true,
|
||||
"#35": false,
|
||||
"#36": false,
|
||||
"#37": false,
|
||||
|
||||
@@ -164,6 +164,7 @@ const FACTOR_FNS = {
|
||||
task_size: (e) => sizeBucket((e.task_size || {}).tool_calls),
|
||||
node_chosen: (e) => (e.primary_rationale || {}).node_chosen || 'direct',
|
||||
task_classification: (e) => (e.primary_rationale || {}).task_classification || 'other',
|
||||
recommended_node_for_direct: (e) => (e.primary_rationale || {}).recommended_node || 'none',
|
||||
};
|
||||
|
||||
/** Factor matrix: rows = factor values, columns = outcome distribution (spec §6). */
|
||||
@@ -199,7 +200,7 @@ export function analyze(episodes, options = {}) {
|
||||
const allNormal = deduped.filter((e) => !e.observer_error);
|
||||
// v1 episodes lack environment / prompt_signal / decision_provenance — they
|
||||
// pollute the factor matrix and break outcome inference. Analyze v2 only.
|
||||
const normal = allNormal.filter((e) => e.schema_version === 2);
|
||||
const normal = allNormal.filter((e) => e.schema_version >= 2);
|
||||
const v1SkippedCount = allNormal.length - normal.length;
|
||||
for (const eps of bySessionSorted(normal).values()) {
|
||||
eps.forEach((episode, i) => {
|
||||
|
||||
@@ -289,3 +289,32 @@ describe('analyze() — missedActivations integration', () => {
|
||||
expect(result.missedActivations.totalMissed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze: schema_version filter', () => {
|
||||
it('accepts both v2 and v3 episodes', () => {
|
||||
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
|
||||
const v3 = { ...v2, schema_version: 3, task_id: 's2', timestamps: { started_at: '2026-05-23T11:00:00Z' },
|
||||
primary_rationale: { ...v2.primary_rationale, recommended_node: '#19' } };
|
||||
const result = analyze([v2, v3]);
|
||||
expect(result.episodeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('factorMatrix has recommended_node_for_direct axis', () => {
|
||||
const v3 = { schema_version: 3, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
|
||||
const result = analyze([v3]);
|
||||
expect(result.factorMatrix.recommended_node_for_direct).toBeDefined();
|
||||
expect(result.factorMatrix.recommended_node_for_direct['#19']).toBeDefined();
|
||||
});
|
||||
|
||||
it('v2 episode bucket=none in recommended_node_for_direct', () => {
|
||||
const v2 = { schema_version: 2, task_id: 's1', timestamps: { started_at: '2026-05-23T10:00:00Z' },
|
||||
prompt_signal: 'new_task', primary_rationale: { node_chosen: 'direct', task_classification: 'feature' },
|
||||
environment: {}, task_size: { tool_calls: 1 }, decision_provenance: { kind: 'autonomous' }, events: [] };
|
||||
const result = analyze([v2]);
|
||||
expect(result.factorMatrix.recommended_node_for_direct.none).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/bin/sh
|
||||
# =============================================================================
|
||||
# tools/git-hooks/pre-commit.sh — нативная замена lefthook-движка
|
||||
# =============================================================================
|
||||
# Зачем: lefthook 2.1.x виснет при `git commit` на этой Windows-машine
|
||||
# (путь с кириллицей + пробелом: "C:\моя\проекты\портал crm\Документация").
|
||||
# Сами проверки отрабатывают и проходят, но движок lefthook не завершается
|
||||
# и плодит node-зомби (см. CHANGELOG / memory feedback_environment q.107+).
|
||||
# Заменено 23.05.2026 по решению заказчика «свой простой скрипт».
|
||||
#
|
||||
# Этот скрипт зеркалит pre-commit джобы lefthook.yml, но:
|
||||
# - вызывает инструменты напрямую (node <entry>, не npx → нет зомби-обёрток)
|
||||
# - НЕ модифицирует index (нет git add / git stash / --fix) → нет конфликта
|
||||
# за .git/index.lock с родительским git commit (корень зависаний lefthook)
|
||||
# - имеет явный exit-код, ничего не висит
|
||||
#
|
||||
# Источник истины КОНФИГУРАЦИИ проверок — lefthook.yml (для CI/Linux, где
|
||||
# lefthook работает штатно). Этот скрипт — локальная Windows-реализация.
|
||||
#
|
||||
# Bypass (как у lefthook): LEFTHOOK=0 git commit ...
|
||||
# =============================================================================
|
||||
|
||||
[ "$LEFTHOOK" = "0" ] && exit 0
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$ROOT" || exit 1
|
||||
|
||||
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
|
||||
[ -z "$STAGED" ] && exit 0
|
||||
|
||||
FAIL=0
|
||||
note() { printf '\n[pre-commit] %s\n' "$1"; }
|
||||
|
||||
# 1. gitleaks — секреты / ПДн / токены в staged (§5.2). Нативный exe.
|
||||
note "gitleaks (secrets)"
|
||||
./bin/gitleaks.exe protect --staged --config .gitleaks.toml --no-banner || { note "gitleaks FAILED"; FAIL=1; }
|
||||
|
||||
# 2+3. markdownlint + cspell на staged .md (исключая вендоренные скилы).
|
||||
# Без --fix: pre-commit не модифицирует файлы. Авто-fix — `npm run lint:md:fix`.
|
||||
MD=$(printf '%s\n' "$STAGED" | grep -E '\.md$' | grep -vE '^\.claude/skills/(mermaid|ccpm|data-scientist|marketingskills)/')
|
||||
if [ -n "$MD" ]; then
|
||||
note "markdownlint"
|
||||
node node_modules/markdownlint-cli2/markdownlint-cli2-bin.mjs $MD || { note "markdownlint FAILED — запусти 'npm run lint:md:fix'"; FAIL=1; }
|
||||
note "cspell"
|
||||
node node_modules/cspell/bin.mjs --no-progress --no-summary --no-gitignore $MD || { note "cspell FAILED — добавь слово в cspell-words.txt или поправь"; FAIL=1; }
|
||||
fi
|
||||
|
||||
# 4. Stylelint на staged .html (CSS в прототипах).
|
||||
HTML=$(printf '%s\n' "$STAGED" | grep -E '\.html$')
|
||||
if [ -n "$HTML" ]; then
|
||||
note "stylelint"
|
||||
node node_modules/stylelint/bin/stylelint.mjs $HTML || { note "stylelint FAILED"; FAIL=1; }
|
||||
fi
|
||||
|
||||
# 5. Pint (--test, без авто-fix) на staged app/**/*.php.
|
||||
# NB: Larastan УБРАН из pre-commit 23.05.2026 — он анализирует весь проект через
|
||||
# phpstan-baseline.neon, который дрейфит от параллельных Claude-сессий и устаревшего
|
||||
# ide-helper (ImportLog @mixin и т.п.) → блокирует несвязанные коммиты сотнями
|
||||
# ignore.unmatched. Larastan остаётся в lefthook.yml (CI/Linux) + ручной `composer stan`
|
||||
# перед push. pint (форматирование, не baseline-зависим) остаётся.
|
||||
PHP=$(printf '%s\n' "$STAGED" | grep -E '^app/.*\.php$')
|
||||
if [ -n "$PHP" ]; then
|
||||
PHP_REL=$(printf '%s\n' "$PHP" | sed 's#^app/##')
|
||||
note "pint --test"
|
||||
( cd app && php vendor/bin/pint --test $PHP_REL ) || { note "pint FAILED — запусти 'cd app && composer pint'"; FAIL=1; }
|
||||
fi
|
||||
|
||||
# 7. squawk на staged *.sql (миграции PostgreSQL).
|
||||
SQL=$(printf '%s\n' "$STAGED" | grep -E '\.sql$')
|
||||
if [ -n "$SQL" ]; then
|
||||
note "squawk"
|
||||
./bin/squawk.exe $SQL || { note "squawk FAILED"; FAIL=1; }
|
||||
fi
|
||||
|
||||
# 8. ESLint на staged app/resources/js/**/*.{ts,vue}.
|
||||
VUE=$(printf '%s\n' "$STAGED" | grep -E '^app/resources/js/.*\.(ts|vue)$')
|
||||
if [ -n "$VUE" ]; then
|
||||
VUE_REL=$(printf '%s\n' "$VUE" | sed 's#^app/##')
|
||||
note "eslint"
|
||||
( cd app && node node_modules/eslint/bin/eslint.js $VUE_REL ) || { note "eslint FAILED"; FAIL=1; }
|
||||
fi
|
||||
|
||||
if [ "$FAIL" = "1" ]; then
|
||||
note "ОТКЛОНЕНО — проверки не пройдены (см. выше). Обход: LEFTHOOK=0 git commit ..."
|
||||
exit 1
|
||||
fi
|
||||
note "OK — все проверки пройдены"
|
||||
exit 0
|
||||
@@ -10,7 +10,11 @@ OnFailure=liderra-queue-alert.service
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=on-failure
|
||||
# Restart=always (не on-failure!): worker раз в час штатно выходит по --max-time=3600
|
||||
# с кодом 0 (success); on-failure такой выход НЕ перезапускает -> очередь умирала
|
||||
# после первой часовой пересменки (инцидент 22.05.2026 17:03 -> простой 12ч, фикс 23.05.2026).
|
||||
# Защита от краш-шторма сохранена через StartLimitBurst=5/300s + OnFailure в [Unit].
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
WorkingDirectory=/var/www/liderra/app
|
||||
# --timeout=300: Laravel default 60s убивал worker до завершения долгих supplier-задач
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Pure deterministic — read-only, no exec, no fs.
|
||||
*
|
||||
* An episode is "missed" iff:
|
||||
* 1. schema_version === 2 (v1 lacks factor data)
|
||||
* 1. schema_version >= 2 (v1 lacks factor data)
|
||||
* 2. NOT observer_error
|
||||
* 3. primary_rationale.task_classification ∈ map AND map[c].length > 0
|
||||
* 4. primary_rationale.node_chosen === 'direct' (no explicit node)
|
||||
@@ -23,7 +23,7 @@ export function detectMissedActivations(episodes, classificationMap, dormancy) {
|
||||
|
||||
for (const e of episodes) {
|
||||
if (!e || e.observer_error) continue;
|
||||
if (e.schema_version !== 2) continue;
|
||||
if (typeof e.schema_version !== 'number' || e.schema_version < 2) continue;
|
||||
const pr = e.primary_rationale || {};
|
||||
const cls = pr.task_classification;
|
||||
const chosen = pr.node_chosen;
|
||||
|
||||
@@ -75,4 +75,10 @@ describe('detectMissedActivations', () => {
|
||||
expect(result.byClassification).toEqual({ refactor: 2, bugfix: 1 });
|
||||
expect(result.totalMissed).toBe(3);
|
||||
});
|
||||
|
||||
it('detects missed activation on v3 episode', () => {
|
||||
const v3 = { schema_version: 3, primary_rationale: { node_chosen: 'direct', task_classification: 'feature', recommended_node: '#19' } };
|
||||
const result = detectMissedActivations([v3], { feature: ['#19'] }, { '#19': false });
|
||||
expect(result.totalMissed).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_note": "node_chosen -> L-цепочки. Только узлы, входящие хотя бы в одну L1-L13. Узлы вне цепочек (direct, прочее) НЕ включаются -> chainsFor вернёт null. Имена ключей = реальные значения primary_rationale.node_chosen (skill-id из skill_invoked). MCP/agent-узлы (laravel-boost, openapi-mcp-server, api-docs, sentry-mcp, redis-mcp, pest, github-mcp) в node_chosen не появляются, но включены для полноты покрытия цепочек L1-L13 (контролёр C6 требует, чтобы каждая L из routing-off-phase.md была покрыта). Синхронизируется с docs/routing-off-phase.md через tools/observer-chain-map-checker.mjs.",
|
||||
"discovery-interview": ["L1", "L2"],
|
||||
"superpowers:brainstorming": ["L1"],
|
||||
"superpowers:brainstorming": ["L1", "L16"],
|
||||
"superpowers:writing-plans": ["L1"],
|
||||
"superpowers:subagent-driven-development": ["L1"],
|
||||
"audit-portal": ["L2"],
|
||||
@@ -46,5 +46,11 @@
|
||||
"owasp-zap": ["L15"],
|
||||
"gitleaks": ["L15"],
|
||||
"semgrep": ["L15"],
|
||||
"trailofbits": ["L15"]
|
||||
"trailofbits": ["L15"],
|
||||
"marketing": ["L16"],
|
||||
"marketing-ru": ["L16"],
|
||||
"yandex-metrika-mcp": ["L16"],
|
||||
"yandex-wordstat-mcp": ["L16"],
|
||||
"telegram-mcp": ["L16"],
|
||||
"postiz": ["L16"]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"$schema_version": 1,
|
||||
"description": "Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime. Classifier vocabulary is Claude's free judgment when writing the episode (no hardcoded enum) — adding a key here makes it 'blessed'. 'security' added 22.05.2026 (A8 follow-up): use when the PURPOSE of the task is verifying or improving security (scans, hardening, audits, threat modeling, go-live gates); NOT for bug-fixes that happen to be in security-relevant code (those stay 'bugfix'). 'marketing' added 22.05.2026 (C1 follow-up): use when the PURPOSE of the task is Лидерра's own marketing/lead-generation (content, SEO, campaigns, RU-channels, landing conversion, marketing-side 152-FZ); NOT for product features, billing flows, or PII-code audits.",
|
||||
"description": "Mapping from observer transcript-parser task_classification values to recommended Tooling Прил.Н node IDs. Source of truth for missed-activation detection (Pravila §16.4 conditional rule). 'other' deliberately empty — no recommendation, never counts as missed. DEFERRED-узлы filtered out by .node-dormancy.json at runtime. Classifier vocabulary is Claude's free judgment when writing the episode (no hardcoded enum) — adding a key here makes it 'blessed'. 'security' added 22.05.2026 (A8 follow-up): use when the PURPOSE of the task is verifying or improving security (scans, hardening, audits, threat modeling, go-live gates); NOT for bug-fixes that happen to be in security-relevant code (those stay 'bugfix'). 'marketing' added 22.05.2026 (C1 follow-up): use when the PURPOSE of the task is Лидерра's own marketing/lead-generation (content, SEO, campaigns, RU-channels, landing conversion, marketing-side 152-FZ); NOT for product features, billing flows, or PII-code audits. 'question' emptied 23.05.2026 (brain-retro #3 A1): conversational Russian Q&A («делай», «а», уточнения) was producing 17/40 false-positive missed-activations against #60 context7 — context7 is for library-docs lookup, not chat. 'memory-sync' emptied 23.05.2026 (brain-retro #3 A2): #33 claude-md-management is the channel for CLAUDE.md edits (Pravila §5 п.10), NOT for memory/*.md (auto-memory writes natively); was producing 8/40 false-positive missed-activations.",
|
||||
"map": {
|
||||
"refactor": ["#11", "#12", "#43", "#64", "#65"],
|
||||
"bugfix": ["#18", "#34"],
|
||||
"feature": ["#19"],
|
||||
"planning": ["#19", "#41", "#42"],
|
||||
"memory-sync": ["#33"],
|
||||
"memory-sync": [],
|
||||
"monitoring": ["#34", "#35"],
|
||||
"analysis": ["#25", "#39", "#53"],
|
||||
"security": ["#73", "#69", "#68", "#70", "#71", "#72"],
|
||||
"marketing": ["#74", "#77", "#75", "#76", "#78", "#79", "#80", "#81"],
|
||||
"cleanup": ["#11", "#12"],
|
||||
"question": ["#60"],
|
||||
"question": [],
|
||||
"other": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Hook resolver for the brain governance observer.
|
||||
* Reverse-lookup .claude/settings.json (+ ~/.claude/settings.json):
|
||||
* matcher (event:tool) → list of hook-script names.
|
||||
*
|
||||
* Pure — no exec, no fs side-effects (Security Guidance #40).
|
||||
* Caller is responsible for reading the JSON; this module operates on
|
||||
* already-parsed settings objects.
|
||||
*
|
||||
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const TOOL_SCRIPT_RE = /(?:^|[\s"'/\\])(tools[\/\\][\w-]+\.(?:mjs|py|sh))/;
|
||||
const NPX_RE = /(?:^|[\s"'])npx\s+(?:-y\s+)?([\w@/.-]+)/;
|
||||
|
||||
/**
|
||||
* Normalize a command string for stable hashing:
|
||||
* - strip surrounding whitespace
|
||||
* - collapse internal whitespace runs to single space
|
||||
* No lowercase (script names are case-sensitive in Windows-aware contexts).
|
||||
*/
|
||||
function normalizeCommand(s) {
|
||||
return String(s || '').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a stable, human-readable identifier from a hook command string.
|
||||
* Priority: tools/X.{mjs,py,sh} → npx <pkg> → inline:<sha-16>.
|
||||
*/
|
||||
export function extractScriptName(command) {
|
||||
const cmd = String(command || '');
|
||||
const toolMatch = cmd.match(TOOL_SCRIPT_RE);
|
||||
if (toolMatch) return toolMatch[1].replace(/\\/g, '/');
|
||||
const npxMatch = cmd.match(NPX_RE);
|
||||
if (npxMatch) return npxMatch[1];
|
||||
const sha = createHash('sha256').update(normalizeCommand(cmd)).digest('hex').slice(0, 16);
|
||||
return `inline:${sha}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build matcher → [scriptName, ...] from one or two settings objects.
|
||||
* Matcher key format:
|
||||
* - "<event>:<tool>" when entry has `matcher` (e.g. "PreToolUse:Bash")
|
||||
* - "<event>" when entry has no `matcher` (UserPromptSubmit, SessionStart)
|
||||
*
|
||||
* Project settings listed before user settings on shared matchers.
|
||||
*/
|
||||
export function buildHookMap(projectSettings = {}, userSettings = {}) {
|
||||
const map = new Map();
|
||||
for (const settings of [projectSettings, userSettings]) {
|
||||
const hooks = settings && settings.hooks;
|
||||
if (!hooks || typeof hooks !== 'object') continue;
|
||||
for (const [event, entries] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const scripts = Array.isArray(entry.hooks) ? entry.hooks : [];
|
||||
const scriptNames = [];
|
||||
for (const h of scripts) {
|
||||
if (!h || h.type !== 'command') continue;
|
||||
scriptNames.push(extractScriptName(h.command));
|
||||
}
|
||||
if (scriptNames.length === 0) continue;
|
||||
const matcherKeys = entry.matcher
|
||||
? String(entry.matcher).split('|').map((t) => `${event}:${t.trim()}`).filter(Boolean)
|
||||
: [event];
|
||||
for (const matcher of matcherKeys) {
|
||||
const existing = map.get(matcher) || [];
|
||||
existing.push(...scriptNames);
|
||||
map.set(matcher, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given matcher counts (from parser hook_fired.counts) and a hook map,
|
||||
* return per-script counts. Each script's count = sum over matchers that
|
||||
* include it of matcherCounts[matcher]. Matchers not in map are skipped
|
||||
* silently (their counts remain reflected in the original `counts` field).
|
||||
*/
|
||||
export function resolveScriptCounts(matcherCounts, hookMap) {
|
||||
const result = {};
|
||||
for (const [matcher, count] of Object.entries(matcherCounts || {})) {
|
||||
const scripts = hookMap.get(matcher);
|
||||
if (!scripts || scripts.length === 0) continue;
|
||||
for (const script of scripts) {
|
||||
result[script] = (result[script] || 0) + count;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildHookMap, resolveScriptCounts, extractScriptName } from './observer-hook-resolver.mjs';
|
||||
|
||||
describe('extractScriptName', () => {
|
||||
it('extracts tools/X.mjs from "node tools/observer-stop-hook.mjs"', () => {
|
||||
expect(extractScriptName('node tools/observer-stop-hook.mjs')).toBe('tools/observer-stop-hook.mjs');
|
||||
});
|
||||
|
||||
it('extracts tools/X.mjs from quoted path with cwd', () => {
|
||||
expect(extractScriptName('node "C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs"'))
|
||||
.toBe('tools/subagent-prompt-prefix.mjs');
|
||||
});
|
||||
|
||||
it('extracts npx package name', () => {
|
||||
expect(extractScriptName('npx -y markdownlint-cli2 --fix file.md')).toBe('markdownlint-cli2');
|
||||
});
|
||||
|
||||
it('falls back to inline:<sha-16> for node -e inline scripts', () => {
|
||||
const result = extractScriptName('node -e "const f=process.env.X; if(f) process.stderr.write(\'warn\');"');
|
||||
expect(result).toMatch(/^inline:[0-9a-f]{16}$/);
|
||||
});
|
||||
|
||||
it('inline fallback is stable across whitespace formatting', () => {
|
||||
const a = extractScriptName('node -e "const f = 1;\n\nif(f) process.exit(0);"');
|
||||
const b = extractScriptName('node -e "const f = 1; if(f) process.exit(0);"');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('inline fallback differs for different commands', () => {
|
||||
const a = extractScriptName('node -e "process.exit(0);"');
|
||||
const b = extractScriptName('node -e "process.exit(1);"');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('extracts tools/X.mjs from Windows backslash path', () => {
|
||||
expect(extractScriptName('node tools\\observer-stop-hook.mjs')).toBe('tools/observer-stop-hook.mjs');
|
||||
});
|
||||
|
||||
it('extracts tools/X.mjs from full Windows abs path with backslashes', () => {
|
||||
expect(extractScriptName('node C:\\path\\tools\\foo.mjs')).toBe('tools/foo.mjs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHookMap', () => {
|
||||
it('returns empty Map for empty settings', () => {
|
||||
expect(buildHookMap({}).size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles missing hooks key', () => {
|
||||
expect(buildHookMap({ permissions: {} }).size).toBe(0);
|
||||
});
|
||||
|
||||
it('builds matcher → [scripts] for single-matcher single-script', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/foo.mjs' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const map = buildHookMap(settings);
|
||||
expect(map.get('PreToolUse:Bash')).toEqual(['tools/foo.mjs']);
|
||||
});
|
||||
|
||||
it('aggregates multiple scripts per matcher', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ matcher: 'Bash', hooks: [
|
||||
{ type: 'command', command: 'node tools/foo.mjs' },
|
||||
{ type: 'command', command: 'node tools/bar.mjs' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(buildHookMap(settings).get('PreToolUse:Bash')).toEqual(['tools/foo.mjs', 'tools/bar.mjs']);
|
||||
});
|
||||
|
||||
it('uses event name without matcher for UserPromptSubmit-style hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
UserPromptSubmit: [
|
||||
{ hooks: [{ type: 'command', command: 'node tools/economy.mjs' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(buildHookMap(settings).get('UserPromptSubmit')).toEqual(['tools/economy.mjs']);
|
||||
});
|
||||
|
||||
it('merges project + user settings (project takes precedence on dup matcher)', () => {
|
||||
const project = {
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/a.mjs' }] }] },
|
||||
};
|
||||
const user = {
|
||||
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node tools/b.mjs' }] }] },
|
||||
};
|
||||
const map = buildHookMap(project, user);
|
||||
expect(map.get('PreToolUse:Bash')).toEqual(['tools/a.mjs', 'tools/b.mjs']);
|
||||
});
|
||||
|
||||
it('splits combined matcher "Edit|Write" into two map entries', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'node tools/guard.mjs' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const map = buildHookMap(settings);
|
||||
expect(map.get('PreToolUse:Edit')).toEqual(['tools/guard.mjs']);
|
||||
expect(map.get('PreToolUse:Write')).toEqual(['tools/guard.mjs']);
|
||||
expect(map.get('PreToolUse:Edit|Write')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('trims whitespace around matchers split on |', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ matcher: 'Edit | Write', hooks: [{ type: 'command', command: 'node tools/g.mjs' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const map = buildHookMap(settings);
|
||||
expect(map.get('PreToolUse:Edit')).toEqual(['tools/g.mjs']);
|
||||
expect(map.get('PreToolUse:Write')).toEqual(['tools/g.mjs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveScriptCounts', () => {
|
||||
it('returns {} for empty matcherCounts', () => {
|
||||
expect(resolveScriptCounts({}, new Map())).toEqual({});
|
||||
});
|
||||
|
||||
it('returns {} when matcher not in map', () => {
|
||||
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, new Map())).toEqual({});
|
||||
});
|
||||
|
||||
it('duplicates count for each script on the matcher', () => {
|
||||
const map = new Map([['PreToolUse:Bash', ['tools/a.mjs', 'tools/b.mjs']]]);
|
||||
expect(resolveScriptCounts({ 'PreToolUse:Bash': 5 }, map)).toEqual({
|
||||
'tools/a.mjs': 5,
|
||||
'tools/b.mjs': 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('sums across multiple matchers that share a script', () => {
|
||||
const map = new Map([
|
||||
['PreToolUse:Bash', ['tools/x.mjs']],
|
||||
['PostToolUse:Bash', ['tools/x.mjs']],
|
||||
]);
|
||||
expect(resolveScriptCounts({ 'PreToolUse:Bash': 3, 'PostToolUse:Bash': 2 }, map))
|
||||
.toEqual({ 'tools/x.mjs': 5 });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@
|
||||
* Used by Stop-hook before JSONL write — per Pravila §16.2 + ADR-011 + spec §5.4.
|
||||
*
|
||||
* Patterns covered:
|
||||
* RU_PHONE — +7XXXXXXXXXX (10 digits after +7)
|
||||
* RU_PHONE — +7XXXXXXXXXX OR bare 7XXXXXXXXXX (11 digits starting with 7,
|
||||
* word-boundary on left). Real-leak regression (gitleaks
|
||||
* 2026-05-23): bare format slipped past `\+7\d{10}`.
|
||||
* EMAIL — any user@domain.tld
|
||||
* JWT — eyJ<base64>.<base64>.<base64> (must run BEFORE OPENAI/Bearer
|
||||
* fallbacks to avoid partial matches)
|
||||
@@ -22,7 +24,7 @@
|
||||
* Security Guidance #40: pure regex — no exec/execSync.
|
||||
*/
|
||||
|
||||
const RU_PHONE = /\+7\d{10}/g;
|
||||
const RU_PHONE = /(?:\+7|\b7)\d{10}/g;
|
||||
const EMAIL = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
|
||||
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g;
|
||||
const AWS_KEY = /\bAKIA[A-Z0-9]{16}\b/g;
|
||||
|
||||
@@ -7,6 +7,23 @@ describe('observer-pii-filter sanitize', () => {
|
||||
expect(sanitize(input)).toBe('Контакт: +7XXXXXXXXXX — позвонить');
|
||||
});
|
||||
|
||||
it('masks bare Russian phone numbers without + prefix (regression: episodes-2026-05 leak)', () => {
|
||||
// Real leak found by gitleaks 2026-05-23: '79135191264' in observer JSONL free-text.
|
||||
const input = 'Утечка телефона: 79135191264 в логе';
|
||||
expect(sanitize(input)).toBe('Утечка телефона: +7XXXXXXXXXX в логе');
|
||||
});
|
||||
|
||||
it('does not match 11-digit sequences embedded in longer numeric strings', () => {
|
||||
// False-positive guard: long IDs / hashes where '7' is mid-digit have no word boundary.
|
||||
const input = 'id 1796133619135191264999 not a phone';
|
||||
expect(sanitize(input)).toBe('id 1796133619135191264999 not a phone');
|
||||
});
|
||||
|
||||
it('masks bare phone inside JSON-like context (quotes, braces)', () => {
|
||||
const input = '{"phone": "79135191264"}';
|
||||
expect(sanitize(input)).toBe('{"phone": "+7XXXXXXXXXX"}');
|
||||
});
|
||||
|
||||
it('masks email addresses', () => {
|
||||
const input = 'Mail: kpd9363@gmail.com';
|
||||
expect(sanitize(input)).toBe('Mail: ***@***');
|
||||
@@ -102,6 +119,11 @@ describe('sanitizeWithCount (Task 3)', () => {
|
||||
expect(counts.RU_PHONE).toBe(1);
|
||||
expect(counts.EMAIL).toBe(1);
|
||||
});
|
||||
|
||||
it('counts bare RU phone (no + prefix) as RU_PHONE pattern', () => {
|
||||
const { counts } = sanitizeWithCount('phone 79135191264 in free text');
|
||||
expect(counts.RU_PHONE).toBe(1);
|
||||
});
|
||||
it('returns zero for absent patterns', () => {
|
||||
const { counts } = sanitizeWithCount('plain text with no PII');
|
||||
expect(counts.RU_PHONE).toBe(0);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Recommended-node resolver for direct episodes.
|
||||
* Pure — read-only, no exec, no fs (Security Guidance #40).
|
||||
*
|
||||
* For an episode classified as `taskClassification` with node_chosen='direct',
|
||||
* return the first live (non-dormant) recommended node ID from the
|
||||
* classification map. Mirrors missed-activations.mjs dormancy logic:
|
||||
* dormancy[id] === false strictly (missing/true → not live).
|
||||
*
|
||||
* Per spec: docs/superpowers/specs/2026-05-23-observer-parser-skill-hook-expand-design.md
|
||||
*/
|
||||
|
||||
export function recommendNode(taskClassification, classificationMap, dormancy) {
|
||||
if (!taskClassification || !classificationMap || !dormancy) return null;
|
||||
const recommended = classificationMap[taskClassification];
|
||||
if (!Array.isArray(recommended) || recommended.length === 0) return null;
|
||||
for (const id of recommended) {
|
||||
if (dormancy[id] === false) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user