Files
clinicpro/.claude/prompt/tenant-07-aggregate-join-guard.md
hamedandClaude Opus 5 0ae9570850 docs(tenant): add prompts for the financial tables and the aggregate-child guard
Phase 6 covers the eight entities parked in GlobalTables::DEFERRED. The reason
recorded there — dual ownership needing separate analysis — turned out to be
wrong: payments carry only two types, both with a derivable environment, and a
patient never has a chosen context so the filter is off for them anyway.

Bank accounts and POS devices move from the user to the environment, per the
product decision. Existing rows whose owner has more than one environment stay
NULL rather than being guessed, since nothing in the data says which clinic an
account belongs to.

Phase 7 addresses the blind spot flagged in phase 4: aggregate children are not
covered by the filter, and the coverage test only proves the declared chain
reaches a tenant-owning root, not that queries actually start there. It may well
conclude no work is needed — the phase 5 audit found no rootless query — in
which case the guard plus the report is the deliverable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:08:17 +03:30

8.6 KiB

فاز ۷ — بستن نقطهٔ کور فرزندان aggregate

ادامهٔ سری tenant. پیش‌نیاز: فاز ۴ (فیلتر) کامل. مستقل از فاز ۶ است و ارزان‌تر — می‌تواند اول اجرا شود.

زمینه

TenantFilter فقط روی entityهایی کار می‌کند که خودشان جفت (entity_type, entity_id) دارند. حدود ۲۵ فرزند aggregate ستون محیط ندارند و از ریشه به ارث می‌برند:

patient_notes · patient_attachments · patient_calls · patient_messages
patient_medical_records · patient_sessions · session_* (۴ جدول)
service_items · service_item_audit_logs · service_item_consumables · service_tariffs
claim_items · claim_status_logs · invoice_items · inventory_package_items
appointment_events · sms_wallet_transactions · tenant_service_coverage · …

TenantSchemaCoverageTest::testEveryAggregateChildReachesATenantOwnedRoot تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای tenant-دار می‌رسد. اما تضمین نمی‌کند کوئری‌های واقعی از ریشه شروع شوند.

سند این را صریح ثبت کرده:

⚠️ فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی patient_attachments بدون JOIN به patient_records، cross-tenant است.

مشکل / هدف

مشکل: تست پوشش سبز است ولی نقطهٔ کور باز — «اطمینان کاذب» که در تحلیل فاز ۴ هم به آن اشاره شد. یک repository جدید که مستقیم روی جدول فرزند کوئری بزند، بی‌صدا cross-tenant می‌شود و هیچ تستی خبر نمی‌دهد.

هدف: هر کوئری‌ای که از یک جدول فرزند شروع می‌شود، یا به ریشه JOIN بزند یا صریح به‌عنوان استثنا ثبت شود.

⚠️ این فاز تور ایمنی رانتایم اضافه نمی‌کند — گارد زمانِ تست است. تور ایمنی واقعی یعنی ستون tenant روی هر ۲۵ جدول، که ۲۵ migration با backfill می‌خواهد و فقط اگر اینجا نشتی واقعی پیدا شد ارزشش را دارد. تصمیم گرفته‌شده: اول گارد، بعد در صورت لزوم ستون.

معیار پذیرش

  • موفق: تستی وجود دارد که همهٔ repositoryهای فرزند aggregate را می‌گردد و ثابت می‌کند هر createQueryBuilder/DQL روی آن‌ها به ریشه JOIN می‌زند؛ روی کد فعلی سبز است
  • خطا: اگر کوئری‌ای بدون JOIN به ریشه پیدا شد، تست با نام کلاس و متد قرمز شود — نه پیام کلی
  • ⚠️ مرزی: کوئری‌ای که عمداً بدون JOIN است (مثلاً شمارش سراسری در کامند ادمین) باید راه ثبت استثنا داشته باشد، وگرنه تیم تست را خاموش می‌کند

فایل‌های مرتبط

فایل نقش
src/Shared/Tenant/GlobalTables.php فهرست AGGREGATE_CHILDREN — ورودی این تست
src/Patient/Repository/* بیشترین تعداد فرزند
src/Billing/Repository/* · src/ClinicService/Repository/* · src/Inventory/Repository/* بقیه
tests/Shared/TenantSchemaCoverageTest.php تست همسایه؛ سبک را از آن بگیر
docs/architecture/tenancy.md بخش «فرزندان aggregate تور ایمنی ندارند» باید به‌روز شود

وضعیت فعلی

// src/Shared/Tenant/GlobalTables.php
/**
 * ⚠️ TenantFilter روی این‌ها اعمال نمی‌شود. کوئری مستقیم روی این جدول‌ها بدون
 * JOIN به ریشه، cross-tenant است — همیشه از ریشه شروع کن.
 *
 * @var array<class-string, class-string> فرزند => ریشه
 */
public const AGGREGATE_CHILDREN = [
    \App\Patient\Entity\PatientAttachment::class => \App\Patient\Entity\PatientRecord::class,
    // … ۲۴ ردیف دیگر
];

این هشدار فقط کامنت است؛ چیزی اجرایش نمی‌کند.

وظایف

۱. سنجش وضع موجود — اول اندازه بگیر

قبل از نوشتن گارد، بفهم چند کوئری واقعاً از جدول فرزند شروع می‌شوند:

ddev exec grep -rln "AGGREGATE_CHILDREN\|PatientNote\|SessionPayment\|ClaimItem" src/*/Repository --include="*.php"

برای هر repositoryِ یک کلاس فرزند، createQueryBuilder/findBy/findOneBy را فهرست کن و دستی تعیین کن کدام از ریشه شروع می‌شود.

اگر خروجی صفر بود — یعنی هیچ کوئری‌ای مستقیم روی فرزندان نیست — این فاز به یک تستِ ساده تقلیل می‌یابد و باید همان را گزارش کنی، نه اینکه گارد پیچیده بسازی.

نحوه تست: خروجی این وظیفه یک جدول در گزارش است: کلاس، متد، «از ریشه شروع می‌شود؟».


۲. گارد تست

بسته به یافتهٔ وظیفهٔ ۱، یکی از دو شکل:

الف) اگر کوئری مستقیم کم است — تستی که DQL هر repositoryِ فرزند را می‌گیرد و بررسی می‌کند نام ریشه در آن هست:

// tests/Shared/AggregateChildQueryGuardTest.php
public function testEveryAggregateChildQueryJoinsItsRoot(): void
{
    // برای هر کلاس در AGGREGATE_CHILDREN، repository متناظر را پیدا کن،
    // متدهای عمومی‌اش را با Reflection بگرد، و DQL تولیدی را بررسی کن.
}

ب) اگر زیاد است — گارد ایستا: فایل‌های src/*/Repository/*.php را بخوان و هر createQueryBuilder('x') روی کلاس فرزند را که در همان متد join/innerJoin به ریشه ندارد گزارش کن.

راه (ب) از (الف) شکننده‌تر است ولی نیازی به اجرای کوئری ندارد. دلیل انتخاب را بنویس.

استثناها در یک ثابت با دلیل، قرینهٔ GlobalTables:

/** @var array<string, string> "Class::method" => دلیل */
private const INTENTIONAL_ROOTLESS_QUERIES = [];

نحوه تست: ddev exec php bin/phpunit tests/Shared/AggregateChildQueryGuardTest.php — روی کد فعلی سبز؛ سپس یک کوئری بدون JOIN موقتاً اضافه کن و تأیید کن قرمز می‌شود (و بعد برش دار).


۳. رفع هر نشتی واقعی

اگر وظیفهٔ ۱ کوئری بدون JOIN پیدا کرد که کاربر غیرادمین به آن می‌رسد، آن یک نشتی واقعی است: JOIN به ریشه اضافه کن و یک تست cross-tenant بگیر — دقیقاً همان الگویی که فاز ۵ برای ClaimRepository استفاده کرد (ClaimsByPatientTest::testAnotherTenantsClaimsNeverAppearInTheDashboard).

نحوه تست: تست جدید: کاربر محیط A نباید ردیف فرزندِ محیط B را ببیند.


۴. سند

بخش «فرزندان aggregate تور ایمنی ندارند» در tenancy.md به‌روز شود: چه چیزی حالا اجبار می‌شود (زمان تست) و چه چیزی همچنان نه (رانتایم).

نکات مهم

  • این فاز ممکن است به «هیچ کاری لازم نیست» ختم شود. آدیت فاز ۵ هیچ کوئری بدون JOIN پیدا نکرد. اگر وظیفهٔ ۱ هم چیزی پیدا نکرد، خروجی درست همان گارد + گزارش است، نه ساختن abstraction برای مسئله‌ای که وجود ندارد.
  • گارد نباید شکننده باشد. تستی که با هر refactor بی‌ربط قرمز شود، خاموش می‌شود و بدتر از نبودنش است. اگر تشخیص مطمئن ممکن نبود، دامنه را کوچک‌تر بگیر (مثلاً فقط جدول‌های بیمار) و همان را قابل اتکا کن.
  • بدون migration، بدون تغییر قرارداد API.
  • اگر نتیجه گرفتی که گارد تست کافی نیست و ستون tenant لازم است، آن را پیاده نکن — پرامپت جدا بنویس و دلیلش را با شواهد وظیفهٔ ۱ مستند کن.