fix(audit): hash-chain canonical → global-within-partition (ADR-021, supersedes ADR-018)
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run

Ночной audit:verify-chains падал на pd_processing_log (ложный «high»-инцидент + письма
kdv1@bk.ru). Проверка на живом кластере (read-only, хеши server-side) показала: ВСЕ 4
«per-tenant» audit-таблицы (activity_log, tenant_operations_log, balance_transactions,
pd_processing_log) записаны ГЛОБАЛЬНОЙ цепочкой (0 global-mismatch), per-tenant верификация
ложно ругается на стыках клиентов. Предпосылка ADR-018 (триггер даёт per-tenant через RLS)
эмпирически неверна — изоляция никогда не была реализована. Данные целостны.

Решение владельца (с учётом что задет и денежный balance_transactions): канон — global.
AuditChainConfig: partition='' для 4 таблиц (verify/rebuild читают конфиг → авто-global).
Данные/схему/триггер/деньги НЕ трогаем (0 mismatch под global, rebuild не нужен).

TDD: новый тест «cross-tenant rows chained by trigger (global) verify intact»; обновлены 3
теста старого per-tenant поведения (config-mirror, per-tenant regression → supersede,
rebuild per-tenant → global). Тесты: 30 audit + 23 Pd/Scheduler зелёные, pint + PHPStan(0) чисто.

Решение задокументировано в docs/adr/ADR-021; ADR-018 → Superseded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-09 08:54:10 +03:00
parent dfa14862b4
commit 1495cd643d
6 changed files with 237 additions and 135 deletions
+9 -6
View File
@@ -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' => [
@@ -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 {
@@ -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(<<<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
// ===========================================================================
@@ -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();
});
@@ -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 {
@@ -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
@@ -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` не трогаем.