Три группы накопившихся auto-правок (НЕ ручные): 1. markdownlint --fix auto-format (~25 .md в docs/superpowers/, docs/security/marketing-vet.md, docs/adr/015, docs/deploy/lkomega-runbook): MD031/MD032 (blank lines around fence/list) + MD004 (bullet markers `+`→`-`). Содержательных текстовых правок 3: ADR-015 bullet, sprint5d-cleanup bullet, router-discipline trailing space. 2. lefthook 2.1.6 → 2.1.8 (package.json + lock): patch-bump, авто-резолвил npm. 3. Observer runtime (docs/observer/): episodes-2026-05.jsonl +420 строк (текущая активность мозга), STATUS.md regen, .pii-counters / .read-counter тики, +2026-05-24-brain-retro.md note. Цель — разблокировать merge feat/llm-first-router → main (этап 0 плана постановки в боевой). Содержание ветки не трогает.
28 KiB
P2 — Operational journaling (projects / API keys / webhook URL / admin-supplier / incidents auto)
Status: ✅ DONE — 22.05.2026. Subagent-driven execution на ветке
worktree-audit-p2-operational(от P0+P1 base). 11 commits (Tasks 1-9 + gate). New schema v8.28/v8.29 (tenant_operations_logtable + 2 indexes + 1 RLS + 2 hash-chain триггера;webhook_logALTER +5 колонок). Все 4 веткиtenant_operations_logпишутся: project.created/updated/deleted/bulk_, api_key.regenerated (key_prefix only — no plain key), webhook_settings.updated. Admin actions (export_mode_set / manual_queue_resolved / projects_destroyed) →saas_admin_audit_log. SupplierWebhook →webhook_log(received/rejected_secret/rejected_ip/rate_limited). Cronincidents:watch-failuresкаждые 10 мин →incidents_logна failure-spike (threshold 200/окно, дедуп 60 мин). Touched-area regression 67/67 passing (275 assertions); Pint clean; Larastan production code clean (0 real findings, 29 Pest TestCall false-positives в новых тестах — environmental, same pattern as P0/P1). NB:--parallelfull-suite не запущен — targeted sequential regression substitute.
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers:executing-plans. Steps use checkbox (- [ ]).
Goal: Закрыть операционные дыры аудита: мутации проектов и settings безопасности (API-ключ, исходящий webhook URL), админ-действия по интеграции с поставщиком, входящий supplier-webhook (включая отказы 404/429) и авто-наполнение incidents_log на основе порога падений (решение D=a: cron-watcher).
Architecture:
- Новый журнал
tenant_operations_log— для мутаций тенант-уровня вне сделок (проекты, API-ключи, webhook-URL). По структуре повторяетactivity_log, но безdeal_id NOT NULL. Защищён теми жеaudit_chain_hash()иaudit_block_mutation()триггерами. - Сервис
App\Services\Audit\OperationsLogger— единственный писательtenant_operations_log. - Admin supplier-integration действия пишутся в существующий
saas_admin_audit_log(структура подходит). SupplierWebhookController.receiveпишетwebhook_logи на success-приёме, и на отказах (404 secret/IP, 429 rate).- Console
incidents:watch-failuresзапускается каждые 10 мин cron-ом, читаетfailed_webhook_jobs+failed_jobsза окно и при превышении порога создаётincidents_logс дедупом по exception-сигнатуре (за окно).
Tech Stack: PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16, миграции через db/migrations/.
File Structure
New (миграция + код + тесты):
db/migrations/2026_05_22_<seq>_tenant_operations_log.sql(raw SQL — паттерн схемы Лидерры) + дополнения кdb/schema.sql.app/app/Services/Audit/OperationsLogger.phpapp/app/Models/TenantOperationsLog.php(Eloquent для чтения, INSERT через сервис).app/app/Console/Commands/IncidentsWatchFailures.phpapp/tests/Unit/Services/Audit/OperationsLoggerTest.phpapp/tests/Feature/Projects/ProjectMutationsAuditTest.phpapp/tests/Feature/Security/ApiKeyRegenerateAuditTest.phpapp/tests/Feature/Security/WebhookUrlChangeAuditTest.phpapp/tests/Feature/Admin/SupplierIntegrationAuditTest.phpapp/tests/Feature/Webhook/SupplierWebhookLoggingTest.phpapp/tests/Feature/Console/IncidentsWatchFailuresTest.php
Modified:
db/schema.sql— добавить определениеtenant_operations_log+ индексы + RLS + триггеры hash-chain.db/CHANGELOG_schema.md— запись v8.X.app/app/Services/Project/ProjectService.php— create/update/delete/bulk → запись.app/app/Http/Controllers/Api/ApiKeyController.php—regenerate→ запись.app/app/Http/Controllers/Api/WebhookSettingsController.php—update→ запись.app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php—setExportMode,manualQueueResolve,projectsDestroy→saas_admin_audit_log.app/app/Http/Controllers/Api/SupplierWebhookController.php—receiveпишетwebhook_logи на success, и на отказах.app/routes/console.php— расписание дляincidents:watch-failures.
Task 1 — Миграция tenant_operations_log
Files:
-
Modify:
db/schema.sql(вставить новый раздел). -
Create:
db/migrations/2026_05_22_001_tenant_operations_log.sql -
Modify:
db/CHANGELOG_schema.md— запись. -
Step 1: добавить таблицу в
db/schema.sql(послеactivity_log, ~строка 1783)
-- =============================================================================
-- tenant_operations_log — журнал тенант-уровневых операций вне сделок
-- (проекты, API-ключи, исходящий webhook URL, и т.п.). Защищён hash-chain.
-- =============================================================================
CREATE TABLE tenant_operations_log (
id BIGSERIAL PRIMARY KEY,
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'
entity_id BIGINT, -- NULL если bulk
event VARCHAR(100) NOT NULL, -- 'project.created', 'api_key.regenerated', ...
payload_before JSONB,
payload_after JSONB,
ip_address INET,
user_agent TEXT,
log_hash BYTEA, -- hash chain (см. audit_chain_hash)
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_tenant_ops_tenant_created
ON tenant_operations_log(tenant_id, created_at DESC);
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);
-- Append-only защита (как для других audit-таблиц, db/schema.sql:3032+):
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();
Также обновить заголовок схемы (счётчик таблиц/индексов/политик/триггеров на +1/+2/+1/+2) и записать v8.X в db/CHANGELOG_schema.md.
- Step 2: создать миграционный файл (raw SQL, паттерн
load_initial_schema.phpдля миграций Лидерры — отдельный файл с CREATE TABLE).
-- db/migrations/2026_05_22_001_tenant_operations_log.sql
-- (содержимое = блок CREATE TABLE + INDEX + RLS + TRIGGERS выше)
- Step 3: накатить на dev и проверить
cd app && php artisan migrate
# или для raw-SQL миграций Лидерры:
psql -U postgres -d liderra -f ../db/migrations/2026_05_22_001_tenant_operations_log.sql
- Step 4: smoke-тест
psql -U postgres -d liderra -c "INSERT INTO tenant_operations_log (tenant_id, entity_type, event) VALUES (1, 'project', 'project.created');"
psql -U postgres -d liderra -c "SELECT id, entity_type, event, encode(log_hash,'hex') FROM tenant_operations_log LIMIT 1;"
psql -U postgres -d liderra -c "UPDATE tenant_operations_log SET event = 'x' WHERE id = 1;"
# Expected: ERROR audit_block_mutation
- Step 5: commit
git add db/schema.sql db/migrations/2026_05_22_001_tenant_operations_log.sql db/CHANGELOG_schema.md
git commit -m "feat(schema): tenant_operations_log table with hash-chain protection"
Task 2 — OperationsLogger сервис
Files:
-
Create:
app/app/Services/Audit/OperationsLogger.php -
Test:
app/tests/Unit/Services/Audit/OperationsLoggerTest.php -
Step 1: failing test — record-вызов пишет строку с правильными полями + проверяет, что UPDATE даёт
QueryException(append-only).
it('inserts tenant_operations_log row', function () {
app(\App\Services\Audit\OperationsLogger::class)->record(
tenantId: 1, userId: 7, entityType: 'project', entityId: 42,
event: 'project.created', payloadBefore: null, payloadAfter: ['name' => 'X'],
ip: '1.2.3.4', userAgent: 'UA',
);
$row = DB::table('tenant_operations_log')->latest('id')->first();
expect($row->event)->toBe('project.created')->and((int) $row->entity_id)->toBe(42);
});
- Step 2: RED
- Step 3: implement
<?php declare(strict_types=1);
namespace App\Services\Audit;
use Illuminate\Support\Facades\DB;
final class OperationsLogger
{
/** @param array<string,mixed>|null $payloadBefore @param array<string,mixed>|null $payloadAfter */
public function record(
int $tenantId,
?int $userId,
string $entityType,
?int $entityId,
string $event,
?array $payloadBefore,
?array $payloadAfter,
?string $ip,
?string $userAgent,
): void {
DB::table('tenant_operations_log')->insert([
'tenant_id' => $tenantId,
'user_id' => $userId,
'entity_type' => $entityType,
'entity_id' => $entityId,
'event' => $event,
'payload_before' => $payloadBefore !== null ? json_encode($payloadBefore, JSON_UNESCAPED_UNICODE) : null,
'payload_after' => $payloadAfter !== null ? json_encode($payloadAfter, JSON_UNESCAPED_UNICODE) : null,
'ip_address' => $ip,
'user_agent' => $userAgent,
'created_at' => now(),
]);
}
}
- Step 4: GREEN
- Step 5: commit
Task 3 — ProjectService мутации → tenant_operations_log
Files:
-
Modify:
app/app/Services/Project/ProjectService.php(create, update, delete, bulk*) -
Test:
app/tests/Feature/Projects/ProjectMutationsAuditTest.php(NEW) -
Step 1: failing test (5 кейсов) —
project.created/project.updated(с diff в payload) /project.deleted/project.bulk_paused/project.bulk_limit_changed(с числами в payload_after). -
Step 2: RED
-
Step 3: implement —
OperationsLoggerв конструктор; вставить вызовы вcreate()/update()/delete()/bulkAction()
class ProjectService
{
public function __construct(private readonly \App\Services\Audit\OperationsLogger $ops) {}
public function create(Tenant $tenant, array $data): Project
{
// ... existing logic up to Project::create($data) ...
$project = Project::create($data);
$this->ops->record(
tenantId: $tenant->id, userId: auth()->id(),
entityType: 'project', entityId: $project->id, event: 'project.created',
payloadBefore: null, payloadAfter: $project->only(['name', 'signal_type', 'daily_limit_target']),
ip: request()->ip(), userAgent: request()->userAgent(),
);
SyncSupplierProjectJob::dispatch($project->id);
return $project->fresh();
}
public function update(Project $project, array $data): Project
{
$before = $project->only(['name', 'daily_limit_target', 'regions', 'delivery_days_mask', 'is_active']);
// ... existing logic ...
$project->update($data);
$this->ops->record(
tenantId: $project->tenant_id, userId: auth()->id(),
entityType: 'project', entityId: $project->id, event: 'project.updated',
payloadBefore: $before, payloadAfter: $project->only(array_keys($before)),
ip: request()->ip(), userAgent: request()->userAgent(),
);
if ($needsResync) { SyncSupplierProjectJob::dispatch($project->id); }
return $project->fresh();
}
public function delete(Project $project): void
{
$before = $project->only(['name', 'signal_type', 'signal_identifier']);
// ... existing logic ...
$this->ops->record(
tenantId: $project->tenant_id, userId: auth()->id(),
entityType: 'project', entityId: $project->id, event: 'project.deleted',
payloadBefore: $before, payloadAfter: null,
ip: request()->ip(), userAgent: request()->userAgent(),
);
$project->delete();
// ...
}
// bulkAction — в каждой ветке match вызвать record с event='project.bulk_<action>'
// и payload содержит ids + параметры (add_regions/remove_regions/delta/replace).
}
- Step 4: GREEN
- Step 5: commit
Task 4 — ApiKeyController.regenerate → tenant_operations_log
Files:
-
Modify:
app/app/Http/Controllers/Api/ApiKeyController.php:41-72 -
Test:
app/tests/Feature/Security/ApiKeyRegenerateAuditTest.php(NEW) -
Step 1: failing test — POST /api/api-keys/regenerate → 1 строка
event='api_key.regenerated', entity_type='api_key', entity_id=<new key id>, payload_after.key_prefix=<prefix>(plain ключ в payload НЕ кладём — secret). -
Step 2: RED
-
Step 3: implement
public function regenerate(Request $request, \App\Services\Audit\OperationsLogger $ops): JsonResponse
{
// ... existing logic up to $key = ApiKey::create([...]) ...
$ops->record(
tenantId: $tenantId, userId: $userId,
entityType: 'api_key', entityId: $key->id, event: 'api_key.regenerated',
payloadBefore: ['deactivated_count' => /* int returned by previous update */],
payloadAfter: ['key_prefix' => $key->key_prefix],
ip: $request->ip(), userAgent: $request->userAgent(),
);
return response()->json([...], Response::HTTP_CREATED);
}
- Step 4: GREEN
- Step 5: commit
Task 5 — WebhookSettingsController.update → tenant_operations_log
Files:
-
Modify:
app/app/Http/Controllers/Api/WebhookSettingsController.php:50-86 -
Test:
app/tests/Feature/Security/WebhookUrlChangeAuditTest.php(NEW) -
Step 1: failing test — PUT /api/tenants/me/webhook-settings → запись
event='webhook_settings.updated', payload_before.target_url=<old>, payload_after.target_url=<new>. -
Step 2: RED
-
Step 3: implement — вызвать
$ops->record(...)после$sub->update([...]). -
Step 4: GREEN
-
Step 5: commit
Task 6 — AdminSupplierIntegrationController (3 mutating action) → saas_admin_audit_log
Files:
-
Modify:
app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php:89,158,234 -
Test:
app/tests/Feature/Admin/SupplierIntegrationAuditTest.php(NEW) -
Step 1: failing test (3 кейса) — setExportMode / manualQueueResolve / projectsDestroy: на каждое — запись
saas_admin_audit_logс правильнымaction='supplier_integration.export_mode_set' / .manual_queue_resolved / .projects_destroyed,payload_before/afterотражают изменение,target_type='system_setting' / 'manual_queue_item' / 'supplier_projects_bulk'. -
Step 2: RED
-
Step 3: implement —
use ResolvesAdminUserId(есть в проекте), injectSaasAdminAuditLogи в каждом методе record
// setExportMode():
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, 'system-supplier@liderra.local', 'System Supplier Bot'),
'action' => 'supplier_integration.export_mode_set',
'target_type' => 'system_setting', 'target_id' => null,
'payload_before' => ['mode' => \App\Services\Supplier\SupplierExportMode::current()],
'payload_after' => ['mode' => $data['mode']],
'reason' => 'Export mode toggle via admin UI.',
'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(),
]);
// manualQueueResolve() — после $row->update(['status' => 'resolved', ...]):
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, ...),
'action' => 'supplier_integration.manual_queue_resolved',
'target_type' => 'manual_queue_item', 'target_id' => $row->id,
'target_tenant_id' => /* from project */,
'payload_before' => ['status' => 'pending'],
'payload_after' => ['status' => 'resolved', 'external_id' => $found],
'reason' => 'Manual queue resolved via admin UI.',
'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(),
]);
// projectsDestroy() — после foreach (или одной строкой с ids):
SaasAdminAuditLog::create([
'admin_user_id' => $this->resolveAdminUserId($request, ...),
'action' => 'supplier_integration.projects_destroyed',
'target_type' => 'supplier_projects_bulk', 'target_id' => null,
'payload_before' => ['requested_ids' => $data['ids']],
'payload_after' => ['deleted_count' => $deleted, 'failures' => $failures],
'reason' => 'Bulk supplier-projects delete via admin UI.',
'ip_address' => $request->ip() ?? '127.0.0.1', 'user_agent' => $request->userAgent(),
]);
- Step 4: GREEN
- Step 5: commit
Task 7 — SupplierWebhookController.receive → webhook_log (success + отказы)
Files:
-
Modify:
app/app/Http/Controllers/Api/SupplierWebhookController.php:47-114 -
Test:
app/tests/Feature/Webhook/SupplierWebhookLoggingTest.php(NEW) -
Step 1: failing test (4 кейса)
it('writes webhook_log on success receive', function () { /* 202 → 1 webhook_log row */ });
it('writes webhook_log on invalid secret 404', function () { /* 404 → 1 row status='rejected_secret' */ });
it('writes webhook_log on IP not allowed 404', function () { /* 404 → 1 row status='rejected_ip' */ });
it('writes webhook_log on rate limit 429', function () { /* 429 → 1 row status='rate_limited' */ });
- Step 2: RED
- Step 3: implement — добавить helper
insertSupplierWebhookLog(?int $leadId, string $status, ?string $error); вызвать на каждой выходной ветке.
private function logSupplierWebhook(Request $request, ?int $leadId, string $status, ?string $error): void
{
if (! \Schema::hasTable('webhook_log')) return;
DB::table('webhook_log')->insert([
'tenant_id' => null, // platform-level
'source' => 'supplier',
'lead_id' => $leadId,
'status' => $status, // 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited'
'ip_address' => $request->ip(),
'error' => $error,
'created_at' => now(),
]);
}
// в receive():
if (! $this->verifySecret($secret)) {
$this->logSupplierWebhook($request, null, 'rejected_secret', null);
return response()->json(['message' => 'Not found.'], 404);
}
if (! $this->verifyIpAllowlist($request->ip())) {
$this->logSupplierWebhook($request, null, 'rejected_ip', null);
return response()->json(['message' => 'Not found.'], 404);
}
if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
$this->logSupplierWebhook($request, null, 'rate_limited', null);
return response()->json([...], 429)->header('Retry-After', (string) $retryAfter);
}
// ... на success после RouteSupplierLeadJob::dispatch:
$this->logSupplierWebhook($request, $lead->id, 'received', null);
Заметка: схема webhook_log — посмотреть текущие колонки в db/schema.sql:1889; если не хватает поля source/status/error — добавить migration / расширить таблицу (отдельный sub-task, в self-review отметить).
- Step 4: GREEN
- Step 5: commit
Task 8 — Cron-watcher incidents:watch-failures
Files:
-
Create:
app/app/Console/Commands/IncidentsWatchFailures.php -
Modify:
app/routes/console.php— добавить расписание. -
Test:
app/tests/Feature/Console/IncidentsWatchFailuresTest.php(NEW) -
Step 1: failing test (3 кейса)
it('creates incident when failed_webhook_jobs spike exceeds threshold', function () {
// создаём 250 строк в failed_webhook_jobs за последние 10 мин с одной exception-сигнатурой
// (порог по умолчанию 200/10мин)
// → artisan incidents:watch-failures
// → ожидаем 1 строку в incidents_log с type='operational', severity='high',
// summary='RouteSupplierLeadJob: <exc head>: 250 за 10 мин'
});
it('does not double-create on second run within window (dedup by signature+window)', function () {
// 1-й run создаёт инцидент; 2-й — НЕ создаёт второй с той же сигнатурой
// (если уже есть открытый incident с этим root_cause за последний час)
});
it('separate signatures → separate incidents', function () {
// 250 ошибок "exception A" + 250 "exception B" → 2 разных incidents_log row
});
- Step 2: RED
- Step 3: implement
<?php declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class IncidentsWatchFailures extends Command
{
/** @var string */
protected $signature = 'incidents:watch-failures
{--window=10 : Окно в минутах}
{--threshold=200 : Порог числа падений за окно}
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
/** @var string */
protected $description = 'Создаёт incidents_log на основе шторма failed_webhook_jobs / failed_jobs';
public function handle(): int
{
$windowMin = (int) $this->option('window');
$threshold = (int) $this->option('threshold');
$dedupMin = (int) $this->option('dedup-window');
$since = Carbon::now()->subMinutes($windowMin);
$dedupSince = Carbon::now()->subMinutes($dedupMin);
// Группируем failed_webhook_jobs за окно по exception-сигнатуре (head 180).
$groups = DB::table('failed_webhook_jobs')
->where('failed_at', '>=', $since)
->selectRaw('LEFT(exception, 180) AS sig, COUNT(*) AS n')
->groupBy('sig')
->having('n', '>=', $threshold)
->get();
$created = 0;
foreach ($groups as $g) {
// дедуп: открытый incident с тем же root_cause за последний час?
$exists = DB::table('incidents_log')
->where('root_cause', $g->sig)
->whereNull('resolved_at')
->where('detected_at', '>=', $dedupSince)
->exists();
if ($exists) continue;
DB::table('incidents_log')->insert([
'type' => 'operational',
'severity' => 'high',
'summary' => sprintf('RouteSupplierLeadJob storm: %d падений за %d мин', $g->n, $windowMin),
'root_cause' => $g->sig,
'started_at' => $since,
'detected_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$created++;
}
$this->info("incidents:watch-failures: created={$created}, groups_above_threshold=".$groups->count());
return self::SUCCESS;
}
}
- Step 4: GREEN
- Step 5: добавить cron
// app/routes/console.php — добавить в конец:
\Illuminate\Support\Facades\Schedule::command('incidents:watch-failures')
->everyTenMinutes()
->timezone('Europe/Moscow');
- Step 6: commit
git add app/app/Console/Commands/IncidentsWatchFailures.php app/routes/console.php app/tests/Feature/Console/IncidentsWatchFailuresTest.php
git commit -m "feat(incidents): cron-watcher auto-populates incidents_log on failure spikes"
Task 9 — Integration: полный operational-flow
Files:
-
Test:
app/tests/Feature/Audit/OperationalFullFlowTest.php -
Step 1: test «полный сценарий»
it('records all operational events end-to-end', function () {
// create project → tenant_ops 'project.created'
// update project (limit change) → tenant_ops 'project.updated' с diff
// regenerate api key → tenant_ops 'api_key.regenerated'
// change webhook url → tenant_ops 'webhook_settings.updated'
// admin set export-mode → saas_admin_audit_log 'supplier_integration.export_mode_set'
// supplier webhook (bad secret) → webhook_log 'rejected_secret'
// simulate 250 failed_webhook_jobs → artisan incidents:watch-failures → incidents_log row
});
- Step 2: RED → GREEN
- Step 3: commit
Task 10 — Full regression (verification gate)
- Step 1: full prod-like прогон
cd app && php artisan test --parallel
cd app && composer pint && composer stan
psql -U postgres -d liderra -c "SELECT 'tenant_operations_log', count(*) FROM tenant_operations_log;"
- Step 2: пометить план DONE
Self-Review
- Spec coverage:
- Project mutations (create/update/delete/bulk) — Tasks 1-3 ✓
- API-key regenerate — Task 4 ✓
- Webhook URL change — Task 5 ✓
- Admin supplier-integration (3 действия) — Task 6 ✓
- Supplier webhook success + 3 отказа — Task 7 ✓
- Incidents auto-population — Task 8 ✓
- Placeholder scan:
bulkAction()в Task 3 описана через паттерн match-веток — конкретный код для каждой ветки (pause/resume/delete/update_regions/update_days/update_limit) пишется по тому же образцу; реальный код для двух примеров (создание/обновление) показан. Если в ходе исполнения окажется, что diff payload даёт слишком много данных — сжать до изменённых ключей (отметка во время задачи). - Type consistency:
OperationsLogger->record(int, ?int, string, ?int, string, ?array, ?array, ?string, ?string)— одинаковая сигнатура во всех точках вызова. - Schema dependency:
webhook_logв Task 7 ожидает колонкиsource/status/error/lead_id. Если их нет в текущей схеме — добавить отдельную миграцию в составе Task 7 (Step 0). - Out-of-scope: ПДн — Plan A; auth events / attribution — Plan B.
Execution
После сохранения — superpowers:subagent-driven-development или superpowers:executing-plans.