diff --git a/app/app/Services/Audit/AuditChainConfig.php b/app/app/Services/Audit/AuditChainConfig.php index f8b91ee7..a6d19ce6 100644 --- a/app/app/Services/Audit/AuditChainConfig.php +++ b/app/app/Services/Audit/AuditChainConfig.php @@ -13,8 +13,11 @@ use InvalidArgumentException; * verify (App\Console\Commands\VerifyAuditChains) and rebuild * (App\Console\Commands\AuditRebuildChain). * - * ADR-018: per-tenant via RLS scope for tenant tables, - * global for BYPASSRLS tables. + * ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц. + * Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая + * balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную + * цепочку (RLS не скоупит его SELECT) — per-tenant изоляция ADR-018 никогда не + * была реализована. Verify/rebuild приведены к реальности (partition => ''). * * columns: list in ordinal_position order from db/schema.sql. * '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW(). @@ -43,7 +46,7 @@ final class AuditChainConfig 'old_value', 'new_value', 'context', 'ip_address', 'user_agent', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'tenant_operations_log' => [ 'columns' => [ @@ -51,7 +54,7 @@ final class AuditChainConfig 'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'balance_transactions' => [ 'columns' => [ @@ -60,7 +63,7 @@ final class AuditChainConfig 'related_type', 'related_id', 'user_id', 'admin_user_id', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'pd_processing_log' => [ 'columns' => [ @@ -68,7 +71,7 @@ final class AuditChainConfig 'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address', '__log_hash__', 'created_at', ], - 'partition' => 'PARTITION BY tenant_id', + 'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован) ], 'saas_admin_audit_log' => [ 'columns' => [ diff --git a/app/tests/Feature/Audit/AuditRebuildChainTest.php b/app/tests/Feature/Audit/AuditRebuildChainTest.php index 73193d74..ce6b80cd 100644 --- a/app/tests/Feature/Audit/AuditRebuildChainTest.php +++ b/app/tests/Feature/Audit/AuditRebuildChainTest.php @@ -232,7 +232,7 @@ it('audit:rebuild-chain rejects unknown partition names', function (): void { // Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']). const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)'; -it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void { +it('audit:rebuild-chain produces global chain matching trigger semantics в activity_log (ADR-021)', function (): void { $tenantA = Tenant::factory()->create(); $tenantB = Tenant::factory()->create(); @@ -253,15 +253,10 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в $partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m'); $firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id'); - // NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env - // `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет - // global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant - // (валидация — live `audit:verify-chains` на проде, не в этом тесте). - // - // Что тестируется здесь: AFTER rebuild чейн должен match семантике своего - // partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG → - // verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4 - // rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN. + // ADR-021 (supersedes ADR-018): канон — global-within-partition для всех таблиц. + // На проде (проверено 2026-07-09) и в test env (postgres superuser) trigger пишет + // GLOBAL chain. rebuild читает partition='' из AuditChainConfig → тоже global → + // verify под global scope match → GREEN. $exit = Artisan::call('audit:rebuild-chain', [ '--partition' => $partition, @@ -270,8 +265,8 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в ]); expect($exit)->toBe(0); - $postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR); - expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)'); + $postMismatches = checkPartitionIntegrity($partition, '', ACTIVITY_LOG_ROW_EXPR); + expect($postMismatches)->toBe(0, 'Rebuild должен produce global chain (ADR-021, supersedes ADR-018 per-tenant)'); }); it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void { diff --git a/app/tests/Feature/Console/VerifyAuditChainsTest.php b/app/tests/Feature/Console/VerifyAuditChainsTest.php index 0034fe28..53fd2f6e 100644 --- a/app/tests/Feature/Console/VerifyAuditChainsTest.php +++ b/app/tests/Feature/Console/VerifyAuditChainsTest.php @@ -151,117 +151,18 @@ test('multiple rows in auth_log all pass intact', function () { }); // =========================================================================== -// MULTI-TENANT REGRESSION: per-scope validator must not false-positive on -// data with multiple tenants. +// MULTI-TENANT REGRESSION — SUPERSEDED by ADR-021. // -// 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. +// The former test here ('per-tenant chained tenant_operations_log validates +// intact') manually seeded PER-TENANT-chained hashes and asserted the per-tenant +// validator accepted them. Its premise — that the trigger produces a per-tenant +// chain on prod — was empirically disproven 2026-07-09: the trigger produces a +// GLOBAL chain for all tables (incl. balance_transactions). ADR-021 makes global +// the canonical scope. The real, prod-accurate scenario (mixed-tenant rows +// chained BY THE TRIGGER = global) is covered by +// 'cross-tenant rows chained by the trigger (global) verify intact — ADR-021'. // =========================================================================== -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(<<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 // =========================================================================== @@ -500,3 +401,36 @@ test('exit code is FAILURE on breach even when no active admin exists for incide // But email alert must still be sent Mail::assertSent(AuditChainBreachMail::class); }); + +// =========================================================================== +// ADR-021: audit hash-chain canonical is GLOBAL-within-partition for all tables. +// The trigger produces a global chain (verified on prod 2026-07-09 for all 4 +// formerly-"per-tenant" tables incl. balance_transactions; also true on +// dev/superuser). Mixed-tenant rows inserted VIA THE TRIGGER must therefore +// verify intact — the old per-tenant validator (ADR-018) false-positived at +// every tenant boundary. Supersedes the per-tenant regression test above. +// =========================================================================== + +test('cross-tenant rows chained by the trigger (global) verify intact — ADR-021', function () { + $t1 = ensureTenant(1); + $t2 = ensureTenant(2); + + // Insert via the normal path (trigger fills log_hash). On dev/superuser the + // trigger chains GLOBALLY — exactly what prod produces. Interleave tenants so + // the chain crosses tenant boundaries (id2:t2 after id1:t1, etc.). + insertTenantOpsRow($t1, 'project.created'); + insertTenantOpsRow($t2, 'api_key.regenerated'); + insertTenantOpsRow($t1, 'project.updated'); + insertTenantOpsRow($t2, 'project.created'); + insertTenantOpsRow($t1, 'project.deleted'); + + // Under global scope (ADR-021) this is a valid intact chain → no breach. + $this->artisan('audit:verify-chains')->assertSuccessful(); + + $count = DB::connection('pgsql_supplier') + ->table('incidents_log') + ->where('summary', 'like', '%chain%tenant_operations_log%') + ->count(); + expect($count)->toBe(0); + Mail::assertNothingSent(); +}); diff --git a/app/tests/Unit/Services/Audit/AuditChainConfigTest.php b/app/tests/Unit/Services/Audit/AuditChainConfigTest.php index 986d24ac..a5473a3d 100644 --- a/app/tests/Unit/Services/Audit/AuditChainConfigTest.php +++ b/app/tests/Unit/Services/Audit/AuditChainConfigTest.php @@ -15,14 +15,13 @@ it('exposes all 6 audit tables', function (): void { ]); }); -it('activity_log uses PARTITION BY tenant_id', function (): void { - expect(AuditChainConfig::TABLES['activity_log']['partition']) - ->toEqual('PARTITION BY tenant_id'); -}); - -it('auth_log and saas_admin_audit_log use global chain (empty partition)', function (): void { - expect(AuditChainConfig::TABLES['auth_log']['partition'])->toEqual(''); - expect(AuditChainConfig::TABLES['saas_admin_audit_log']['partition'])->toEqual(''); +it('all 6 audit tables use global-within-partition chain (ADR-021, supersedes ADR-018)', function (): void { + // ADR-021: триггер де-факто пишет глобальную цепочку для ВСЕХ таблиц (проверено на + // проде 2026-07-09, включая balance_transactions). per-tenant scope ADR-018 никогда + // не был реализован → все partition-clause пустые (global-within-partition). + foreach (AuditChainConfig::TABLES as $table => $cfg) { + expect($cfg['partition'])->toEqual('', "table {$table} must use global chain"); + } }); it('rowExpression builds ROW(...) with NULL::bytea at __log_hash__ position', function (): void { diff --git a/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md b/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md index a16758de..166a7ab3 100644 --- a/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md +++ b/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md @@ -1,9 +1,15 @@ # ADR-018: Audit hash-chain semantics — per-tenant (через RLS scope) canonical -- **Status:** Accepted +- **Status:** Superseded by [ADR-021](ADR-021-audit-chain-global-scope.md) (2026-07-09) - **Date:** 2026-05-29 - **Deciders:** User: Дмитрий (business policy 152-ФЗ) +> ⚠️ **Superseded 2026-07-09 (ADR-021):** предпосылка этого ADR — что триггер +> `audit_chain_hash()` под RLS даёт per-tenant цепочку — **эмпирически опровергнута на проде**: +> все 4 audit-таблицы (включая `balance_transactions`) записаны глобальной цепочкой, per-tenant +> изоляция никогда не была реализована. Канон изменён на global-within-partition. Текст ниже +> сохранён без изменений (immutability). Детали и данные — в ADR-021. + ## Context Портал ведёт 6 append-only audit-таблиц с криптографической SHA-256 hash-chain diff --git a/docs/adr/ADR-021-audit-chain-global-scope.md b/docs/adr/ADR-021-audit-chain-global-scope.md new file mode 100644 index 00000000..3dcd896d --- /dev/null +++ b/docs/adr/ADR-021-audit-chain-global-scope.md @@ -0,0 +1,165 @@ +# ADR-021: Audit hash-chain semantics — global-within-partition canonical (supersedes ADR-018) + +- **Status:** Accepted. Date: 2026-07-09. +- **Decision Maker:** User: Дмитрий (152-ФЗ business policy), после эмпирической проверки на проде. +- **Supersedes:** ADR-018 (Audit hash-chain semantics — per-tenant canonical). + +## Context + +[ADR-018](ADR-018-audit-chain-per-tenant-semantics.md) (2026-05-29) зафиксировал **per-tenant** +как канон hash-chain для четырёх tenant-таблиц (`activity_log`, `tenant_operations_log`, +`balance_transactions`, `pd_processing_log`). Решение опиралось на **предположение**, что +триггер `audit_chain_hash()` под RLS вставляющей сессии видит только строки своего tenant'а и +потому автоматически строит per-tenant цепочку (ADR-018 §36). + +09.07.2026 ночной `audit:verify-chains` начал падать (exit 1) на +`pd_processing_log_y2026_m07`, первый сломанный `id=29`, писать high-инцидент в `incidents_log` +и слать письма на `kdv1@bk.ru`. Разрыв приходился ровно на границу клиентов (id=28 tenant 2 → +id=29 tenant 1, свежая запись `lead_create_supplier`). + +Эмпирическая проверка на живом кластере `c9q2cvtjpq3hgq6l0r96` (read-only; хеши считались +на стороне PostgreSQL через `digest(...)`, персональные данные прод не покидали — сравнивались +только булевы результаты) по **всем** per-tenant audit-таблицам: + +| Таблица | Строк | GLOBAL mismatch | PER-TENANT mismatch | +|---|---|---|---| +| `activity_log` | 112 | **0** | 1 | +| `tenant_operations_log` | 45 | **0** | 2 | +| `balance_transactions` (деньги) | 168 | **0** | 2 | +| `pd_processing_log` | 126 | **0** | 1 | + +Вывод, подтверждённый хешами: на проде **все** audit-таблицы записаны **глобальной** цепочкой +(0 несовпадений под global-обходом), а per-tenant верификация ложно срабатывает на каждом стыке +клиентов. То есть **предположение ADR-018 эмпирически ложно — per-tenant изоляция цепочки +никогда не была реализована** (триггер де-факто читает последнюю строку партиции глобально, +несмотря на RLS и `SET LOCAL app.current_tenant_id`). Данные при этом **целостны** (globally +consistent, тамперинга нет). + +Записи `pd_processing_log`/`activity_log`/`balance_transactions` пишутся в ОДНОЙ транзакции +`RouteSupplierLeadJob` ([app/app/Jobs/RouteSupplierLeadJob.php:475-491](../../app/app/Jobs/RouteSupplierLeadJob.php#L475)), +поэтому попытка «сделать per-tenant по-настоящему» неизбежно затрагивает и денежный +`balance_transactions`. + +## Decision + +**Канон audit hash-chain — global-within-partition для всех 6 таблиц.** В +`AuditChainConfig::TABLES` для `activity_log` / `tenant_operations_log` / `balance_transactions` +/ `pd_processing_log` установить `partition => ''` (как уже у `auth_log` / `saas_admin_audit_log`). +`VerifyAuditChains` и `AuditRebuildChain` читают этот конфиг → автоматически переходят на global. + +Данные **не пересобираются** — они уже globally-consistent (0 mismatch под global). Триггер +`audit_chain_hash()` **не меняется** (он уже де-факто глобальный). Схема БД и деньги не +трогаются. Изменение — только PHP-конфиг + тесты. + +## Alternatives Considered + +### Alternative A: Enforce per-tenant по-настоящему (отклонено) + +Переделать триггер `audit_chain_hash()` на реальный per-tenant scope (SECURITY DEFINER + явная +фильтрация по tenant_id, или гарантированный RLS-контекст у всех писателей) и пересчитать +(`audit:rebuild-chain`) hash-цепочку во **всех** четырёх журналах, включая денежный +`balance_transactions`. + +**User Feedback (Дмитрий, 09.07):** отклонено. Большая рискованная операция на проде по денежным +записям ради изоляции, которая **никогда не работала**. Ценность не оправдывает риск. + +**Cons:** высокий риск на проде; трогает money audit-chain; повышает attack surface триггера +(SECURITY DEFINER); долгий проект. + +### Alternative B: Global canonical (выбрано) + +Как описано в Decision. + +**Pros:** verify сходится (0 mismatch); ложные ночные инциденты и письма прекращаются; БД и +деньги не трогаем; writer / verify / rebuild становятся консистентны с реальностью; +tamper-detection сохраняется (любая подмена рвёт общую цепь — строже, чем per-tenant). + +**Cons:** формально отменяет per-tenant решение ADR-018. + +### Alternative C: Do nothing (отклонено) + +Оставить как есть. + +**Cons:** каждую ночь ложный high-инцидент + письма на `kdv1@bk.ru`; накопление вечно-открытых +инцидентов; расхождение writer/verify не закрыто. + +## Consequences + +**Benefits** + +- `audit:verify-chains` проходит (0 mismatch по всем 6 таблицам) — ложные алёрты прекращаются. +- Никаких изменений данных, схемы, триггера, денег — минимальный риск (одна PHP-константа + тесты). +- Единая семантика во всех трёх местах (writer / verify / rebuild) — соответствует реальности. +- Tamper-detection не ослаблен: общая цепь рвётся при любой подмене где угодно в партиции. + +**Trade-offs** + +- Теряется аспирационная per-tenant изоляция журналов из ADR-018 — но она de-facto никогда не + была включена, поэтому реальной защиты не убывает. + +**Risks and mitigations** + +- *Risk:* 152-ФЗ может юридически требовать раздельного/изолированного хранения журналов на + клиента. *Mitigation:* глобальная hash-цепь удовлетворяет требование **фиксации и + неизменяемости** операций обработки ПДн (ст.18 ч.2). Строгая per-tenant **изоляция** журналов — + отдельный юридический вопрос; при необходимости — отдельный проект с юристом (Alternative A). +- *Risk:* будущий писатель начнёт (правильно) писать per-tenant → снова разъедется. *Mitigation:* + зафиксировано в этом ADR + enforcement на общий `AuditChainConfig`. + +**Open item (не блокирует решение)** + +Точная причина, почему триггер видит строки глобально несмотря на RLS-политику +`tenant_isolation` и `SET LOCAL app.current_tenant_id` в `RouteSupplierLeadJob` (роль очереди / +RLS / потенциальный BYPASSRLS на проде) — не докопана. На решение не влияет (принимаем +глобальную реальность как канон), но стоит зафиксировать для будущего RLS-ревью — вдруг +изоляция ПДн задумывалась и на другом уровне. + +## Related Decisions + +- **ADR-018 (Audit hash-chain — per-tenant)** — **superseded этим ADR.** Его предпосылка + (триггер даёт per-tenant через RLS) эмпирически опровергнута на проде 09.07.2026. +- **ADR-002 (Multi-tenancy через PostgreSQL RLS)** — RLS остаётся источником изоляции **данных**; + этот ADR декуплирует scope audit-цепочки от RLS (цепочка — глобальная в пределах партиции). + +## References + +- Prod-измерения 2026-07-09 (read-only, live cluster) — таблица в Context. +- [app/app/Services/Audit/AuditChainConfig.php](../../app/app/Services/Audit/AuditChainConfig.php) — `partition` per table (место правки). +- [app/app/Console/Commands/VerifyAuditChains.php](../../app/app/Console/Commands/VerifyAuditChains.php) — читает конфиг. +- [app/app/Console/Commands/AuditRebuildChain.php](../../app/app/Console/Commands/AuditRebuildChain.php) — читает конфиг. +- [db/schema.sql](../../db/schema.sql) — `audit_chain_hash()` (не меняется). +- [app/app/Jobs/RouteSupplierLeadJob.php:475-491](../../app/app/Jobs/RouteSupplierLeadJob.php#L475) — совместная транзакция pd/activity/balance. +- Memory: `project-audit-chain-pdlog-cross-tenant-divergence-2026-07-09`. +- 152-ФЗ ст.18 ч.2 — фиксация операций обработки ПДн. + +## Enforcement + +```json +{ + "rules": [ + { + "id": "verify-must-use-shared-config", + "description": "VerifyAuditChains должна читать TABLES из AuditChainConfig — не дублировать semantics локально", + "applies_to": ["app/app/Console/Commands/VerifyAuditChains.php"], + "require_pattern": "AuditChainConfig::TABLES|AuditChainConfig::rowExpression" + }, + { + "id": "rebuild-must-use-shared-config", + "description": "AuditRebuildChain должна читать partition_clause из AuditChainConfig — не определять semantics локально", + "applies_to": ["app/app/Console/Commands/AuditRebuildChain.php"], + "require_pattern": "AuditChainConfig::TABLES|AuditChainConfig::rowExpression" + } + ], + "llm_judge": false +} +``` + +## Implementation (отдельный шаг, TBD) + +Само изменение кода (не входит в этот ADR-record): + +1. `AuditChainConfig::TABLES` — `partition => ''` для `activity_log`, `tenant_operations_log`, + `balance_transactions`, `pd_processing_log`. +2. TDD-тест: `VerifyAuditChains` проходит для смешанных cross-tenant партиций под global scope. +3. Прогнать `audit:verify-chains` (dev → прод read-only) — ожидаем 0 mismatch без rebuild. +4. Выкат PHP-оверлеем (без миграций, без rebuild данных). Схему БД и `db/CHANGELOG_schema.md` не трогаем.