feat(tenant): give a tenant pair to the children reachable by a request uuid

Phase 7 concluded that aggregate children needed no column of their own,
because every repository query anchors to its root. That was true of the
repositories, and it missed the case where the anchor never happens:

    $item = $this->serviceItemRepo->findByUuid($data['service_item_uuid']);

A lookup by uuid is itself an unanchored query, and TenantFilter cannot help
when the table has no column to filter on. All three leaks phase 7 found had
exactly this shape, including the one that put another environment's service
price on a patient's invoice.

Measuring which children are actually loaded that way gives eight of the
twenty-five — service_items (15 call sites), patient_sessions (7),
session_payments, patient_notes, patient_calls, patient_messages,
patient_attachments, patient_medical_records. They now carry their own pair
and leave AGGREGATE_CHILDREN; the other seventeen are only ever traversed
from their root and stay as they were.

The pair is derived from the root inside the constructor rather than passed
in, so no creation site can forget it and the value has one source. A root
never changes environment, so the copy is written once and cannot drift.

This is defence at the data layer rather than at the entry point: a forgotten
guard now returns nothing instead of another environment's row. The existing
TenantOwnershipChecker guards stay as the outer layer.

Verified against an imported production database: 8 tables backfilled, zero
rows unmatched, zero rows inconsistent with their root. Dropping the column
again turns the leak test red.

Tests: 911 backend (+5). PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 16:50:51 +03:30
co-authored by Claude Opus 5
parent 74d2034158
commit a95ee9a618
12 changed files with 323 additions and 18 deletions
+4 -8
View File
@@ -80,23 +80,19 @@ final class GlobalTables
* ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون
* JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن.
*
* فرزندی که uuidش از خودِ درخواست می‌آید نباید اینجا بماند: چنین جست‌وجویی
* ذاتاً بی‌لنگر است و تور ایمنی ندارد. هشت مورد از این دست جفت محیط خودشان را
* گرفتند (فاز ۸)؛ باقی‌مانده‌ها فقط از ریشه پیمایش می‌شوند.
*
* @var array<class-string, class-string> فرزند => ریشه
*/
public const AGGREGATE_CHILDREN = [
\App\Appointment\Entity\AppointmentEvent::class => \App\Appointment\Entity\Appointment::class,
\App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientCall::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientMedicalRecord::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientMessage::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientNote::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\PatientSession::class => \App\Patient\Entity\PatientRecord::class,
\App\Patient\Entity\SessionAuditLog::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionConsumable::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionPayment::class => \App\Patient\Entity\PatientSession::class,
\App\Patient\Entity\SessionService::class => \App\Patient\Entity\PatientSession::class,
\App\ClinicService\Entity\ServiceItem::class => \App\ClinicService\Entity\ServiceSection::class,
\App\ClinicService\Entity\ServiceItemAuditLog::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\ServiceItemConsumable::class => \App\ClinicService\Entity\ServiceItem::class,
\App\ClinicService\Entity\Tariff::class => \App\ClinicService\Entity\ServiceItem::class,