diff --git a/assets/admin/components/RichTextEditor.tsx b/assets/admin/components/RichTextEditor.tsx
new file mode 100644
index 00000000..b0f8d0b5
--- /dev/null
+++ b/assets/admin/components/RichTextEditor.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { CKEditor } from '@ckeditor/ckeditor5-react';
+import {
+ ClassicEditor,
+ Essentials,
+ Paragraph,
+ Heading,
+ Bold,
+ Italic,
+ Link,
+ List,
+ BlockQuote,
+ Table,
+ TableToolbar,
+ Undo,
+} from 'ckeditor5';
+import translations from 'ckeditor5/translations/fa.js';
+import 'ckeditor5/ckeditor5.css';
+
+/**
+ * ادیتور متن غنی مقالهها.
+ *
+ * تا ۲۰۲۶-۰۸-۰۸ هر دو صفحهٔ مقاله مستقیم `@ckeditor/ckeditor5-build-classic` را
+ * import میکردند. آن پکیج deprecated بود و ۶۲ advisory داشت (یافتهٔ ۴ آدیت
+ * ۲۰۲۶-۰۸-۰۷). جایگزینش پکیج umbrella `ckeditor5` است که در آن، برخلاف build
+ * آماده، فهرست پلاگینها صریح است.
+ *
+ * پیکربندی اینجا متمرکز شد تا مهاجرت بعدی یک فایل باشد نه دو صفحه — و تا نوار
+ * ابزارِ دو صفحه از هم واگرا نشود.
+ *
+ * فهرست پلاگینها دقیقاً همان دکمههای نوار ابزارِ قبلی است، نه بیشتر: هر پلاگین
+ * اضافه یعنی markup تازهای که `html_sanitizer.yaml` هنوز مجازش نکرده و هنگام
+ * ذخیره حذف میشود.
+ */
+export default function RichTextEditor({
+ value,
+ onChange,
+}: {
+ value: string;
+ onChange: (html: string) => void;
+}) {
+ return (
+
+ onChange(editor.getData())}
+ config={{
+ licenseKey: 'GPL',
+ language: 'fa',
+ translations: [translations],
+ plugins: [
+ Essentials,
+ Paragraph,
+ Heading,
+ Bold,
+ Italic,
+ Link,
+ List,
+ BlockQuote,
+ Table,
+ TableToolbar,
+ Undo,
+ ],
+ toolbar: [
+ 'heading', '|',
+ 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|',
+ 'blockQuote', 'insertTable', '|',
+ 'undo', 'redo',
+ ],
+ table: {
+ contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
+ },
+ }}
+ />
+
+ );
+}
diff --git a/assets/admin/pages/BlogFormPage.test.tsx b/assets/admin/pages/BlogFormPage.test.tsx
index c7520fe0..06be5ce4 100644
--- a/assets/admin/pages/BlogFormPage.test.tsx
+++ b/assets/admin/pages/BlogFormPage.test.tsx
@@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
import { Routes, Route } from 'react-router';
import { renderWithProviders } from '@/test/utils';
-vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null }));
-vi.mock('@ckeditor/ckeditor5-build-classic', () => ({ default: {} }));
+// ادیتور در jsdom بالا نمیآید و به این تست ربطی ندارد؛ کلِ wrapper mock میشود
+// تا mockهای پکیجهای داخلیاش با هر مهاجرت CKEditor عوض نشوند.
+vi.mock('../components/RichTextEditor', () => ({ default: () => null }));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/components/ui/SearchableSelect', () => ({ default: () => null }));
vi.mock('@/lib/api', () => ({
diff --git a/assets/admin/pages/BlogFormPage.tsx b/assets/admin/pages/BlogFormPage.tsx
index 9f5e7c73..a94bbb7a 100644
--- a/assets/admin/pages/BlogFormPage.tsx
+++ b/assets/admin/pages/BlogFormPage.tsx
@@ -4,8 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
-import { CKEditor } from '@ckeditor/ckeditor5-react';
-import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
+import RichTextEditor from '../components/RichTextEditor';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Blog, City } from '../types';
@@ -174,18 +173,7 @@ export default function BlogFormPage() {
control={control}
name="body"
render={({ field }) => (
-
- string }) => field.onChange(editor.getData())}
- config={{
- licenseKey: 'GPL',
- language: 'fa',
- toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'],
- }}
- />
-
+
)}
/>
{errors.body && {errors.body.message}
}
diff --git a/assets/admin/pages/RepresentationBlogFormPage.tsx b/assets/admin/pages/RepresentationBlogFormPage.tsx
index c8fb01e2..0dca36b4 100644
--- a/assets/admin/pages/RepresentationBlogFormPage.tsx
+++ b/assets/admin/pages/RepresentationBlogFormPage.tsx
@@ -3,8 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
-import { CKEditor } from '@ckeditor/ckeditor5-react';
-import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
+import RichTextEditor from '../components/RichTextEditor';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Blog } from '../types';
@@ -117,14 +116,7 @@ export default function RepresentationBlogFormPage() {
control={control}
name="body"
render={({ field }) => (
-
- string }) => field.onChange(editor.getData())}
- config={{ licenseKey: 'GPL', language: 'fa', toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'] }}
- />
-
+
)}
/>
{errors.body && {errors.body.message}
}
diff --git a/assets/admin/styles.css b/assets/admin/styles.css
index 9dd46720..573d919b 100644
--- a/assets/admin/styles.css
+++ b/assets/admin/styles.css
@@ -1020,6 +1020,16 @@ html, body { max-width: 100%; overflow-x: hidden; }
.wh-two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--gap); align-items: start; }
@media (max-width: 1100px) { .wh-two-col { grid-template-columns: minmax(0, 1fr); } }
+/* ── بدنهٔ مقاله در صفحهٔ بازبینی ───────────────────────────────────────────
+ جدولهای مقالهها تا ۲۰۲۶-۰۸-۰۸ ظاهرشان را از `style` inline میگرفتند. آن
+ attribute در sanitizer عمداً ممنوع است — تنها attributeِ ظاهریِ جدول که
+ میتواند بارِ اجرایی حمل کند — پس همان ظاهر اینجا، در لایهٔ درست، بازسازی
+ میشود. `border` و `cellpadding` هنوز از خودِ HTML میآیند. */
+.blog-body table { border-collapse: collapse; width: 100%; }
+.blog-body th, .blog-body td { border: 1px solid var(--border); padding: 8px; }
+.blog-body th { background: var(--surface-2); font-weight: 600; }
+.blog-body img { max-width: 100%; height: auto; }
+
/* ── CKEditor 5 ────────────────────────────────────────────────────────────
ادیتور همهٔ رنگهایش را از متغیرهای --ck-color-* خودش میگیرد و پیشفرض آنها
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید میماند. */
diff --git a/config/packages/html_sanitizer.yaml b/config/packages/html_sanitizer.yaml
index f438694c..336922b7 100644
--- a/config/packages/html_sanitizer.yaml
+++ b/config/packages/html_sanitizer.yaml
@@ -45,7 +45,12 @@ framework:
div: []
a: ['href', 'title', 'target', 'rel']
img: ['src', 'alt', 'title', 'width', 'height']
- table: []
+ # سه attributeِ ظاهریِ قدیمیِ جدول عمداً مجازند. هیچکدام
+ # نمیتوانند حاملِ اسکریپت یا URL باشند — مقدارشان عدد است — و
+ # بدونشان جدولِ صدها مقالهٔ موجود حاشیه و فاصلهاش را از دست
+ # میداد. `style` همچنان ممنوع است: تنها attributeِ ظاهریِ این
+ # جمع که میتواند بارِ اجرایی حمل کند.
+ table: ['border', 'cellpadding', 'cellspacing']
thead: []
tbody: []
tfoot: []
diff --git a/docs/api/blog.md b/docs/api/blog.md
index eceadc60..9a7d8fd9 100644
--- a/docs/api/blog.md
+++ b/docs/api/blog.md
@@ -296,7 +296,7 @@ Create a new blog post.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | ✅ | Post title (slug auto-generated) |
-| `body` | string | ✅ | Full HTML body (from the admin CKEditor) |
+| `body` | string | ✅ | Full HTML body (from the admin CKEditor). **پیش از ذخیره پاکسازی میشود** — [سیاست پاکسازی بدنه](#سیاست-پاکسازی-بدنه). |
| `summary` | string | ❌ | Short excerpt |
| `tags` | integer[] | ❌ | Array of tag IDs |
| `status` | string | ❌ | `"draft"` (default) or `"published"` |
@@ -582,6 +582,47 @@ Upload blog post header image.
---
+## سیاست پاکسازی بدنه
+
+بدنهٔ مقاله در **لحظهٔ ذخیره** پاکسازی میشود، نه هنگام نمایش — هر چهار نقطهٔ ورود
+(`POST`/`PATCH` در `BlogController` و `RepresentationBlogController`) از
+`App\Blog\Service\BlogBodySanitizer` میگذرند. دلیلش چند مصرفکننده بودنِ بدنه است:
+پنل ادمین، سایت عمومی و فید. با پاکسازی در لایهٔ نمایش، هر مصرفکنندهٔ تازه دوباره
+آسیبپذیر شروع میکرد.
+
+سیاست در `config/packages/html_sanitizer.yaml` است. سه قاعدهای که رفتار قابلمشاهده
+دارند:
+
+- `` به متنِ `alert(1)` تبدیل میشد.
+- روی هر `` مقدار `rel="noopener noreferrer"` **تحمیل** میشود؛ طرحهای مجاز فقط
+ `http`, `https`, `mailto`.
+- `` سه attributeِ ظاهریِ قدیمی را نگه میدارد — `border`, `cellpadding`,
+ `cellspacing` — ولی `style` را از **هر** عنصری حذف میکند. ظاهرِ جدولِ بدنهٔ مقاله
+ در پنل از `.blog-body` در `assets/admin/styles.css` میآید.
+
+بدنهای که پس از پاکسازی خالی شود `422` میگیرد، نه اینکه خالی ذخیره شود.
+
+### پاکسازی مقالههای قدیمی
+
+مقالههایی که پیش از این سیاست ذخیره شدهاند با یک دستور به همان وضع میرسند:
+
+```bash
+ddev exec php bin/console app:blog:sanitize-bodies --dry-run # گزارش، بدون نوشتن
+ddev exec php bin/console app:blog:sanitize-bodies # اعمال
+ddev exec php bin/console app:blog:sanitize-bodies --show=42 # قبل/بعدِ یک مقاله
+```
+
+گزارشِ dry-run تغییرها را تفکیک میکند: «سختسازی» یعنی فقط `rel` اضافه یا entity
+decode شده، و «حذفِ تگ یا attribute غیرمجاز» یعنی آن مقاله markupی داشته که سیاست
+نمیپذیرد. بدونِ این تفکیک، عددِ کلِ تغییرات گمراهکننده است — در اجرای ۲۰۲۶-۰۸-۰۸ از
+۴۲۶ مقالهٔ تغییریافته، ۳۹۸ فقط سختسازی بودند.
+
+دستور idempotent است: اجرای دوم صفر تغییر گزارش میدهد.
+
+---
+
## باطلسازی کش سایت عمومی (webhook خروجی)
سایت عمومی (`nobat724_front`) پاسخ `GET /api/v1/blog/{slug}` را با `next: { revalidate: 3600, tags: [...] }` کش میکند. بدون باطلسازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمیشد.
diff --git a/docs/security/AUDIT-2026-08-07.md b/docs/security/AUDIT-2026-08-07.md
index 064dc7a5..a2a550ff 100644
--- a/docs/security/AUDIT-2026-08-07.md
+++ b/docs/security/AUDIT-2026-08-07.md
@@ -21,24 +21,27 @@ authz/headers/cors/inject) + پروبهای دستی با JWT واقعی هر
| 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` میتوانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد |
| 2 | `react-router` — ۵ advisory از جمله XSS و open redirect | 🟧 High | ✅ رفع شد (مهاجرت به v8) |
| 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ✅ رفع شد (override به 4.18.1) |
-| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ⚠️ risk پذیرفتهشده — تصمیم ۲۰۲۶-۰۸-۰۷ |
+| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ (مهاجرت به `ckeditor5@48`) |
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) |
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد |
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
| 8 | ۱۰ روت `GET` که مجوزِ رجیستریشان را enforce نمیکنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
-| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ⚠️ باز — فهرست کامل زیر |
+| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ◐ نیمهرفع — ۲۲ روت بسته شد، ۱۲ روت باز |
+| 11 | `MyAppointmentsController::$branches` تزریق نشده بود — اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰ میداد | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
+| 12 | سیاست پاکسازی، `style` جدول را میبرد — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵ | 🟦 Low | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافتههای باز را خواست، پس
-یافتههای ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸
-حین همین کار کشف شد.
+یافتههای ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۸ حین همین کار کشف شد.
-یافتههای ۹ و ۱۰ در جلسهٔ ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتهٔ ۸. شرحشان در بخش
+یافتههای ۹ تا ۱۲ در جلسههای ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتههای ۸ و ۴. شرحشان در بخش
«پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
```bash
-npm audit --omit=dev # قبل: high=2 moderate=62 بعد: high=0 moderate=3
-ddev exec php bin/phpunit # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز
+npm audit --omit=dev # ۲۰۲۶-۰۸-۰۷ قبل: high=2 moderate=62 · بعد: high=0 moderate=3
+ # ۲۰۲۶-۰۸-۰۸ پس از مهاجرت CKEditor: ۰ آسیبپذیری
+ddev exec php bin/phpunit # ۱۵۶۸ تست سبز (بود ۱۵۵۵)
+ddev exec php vendor/bin/phpstan # No errors (بود ۱۷ خطا بیرون از baseline)
```
---
@@ -681,18 +684,177 @@ fail-closed مستندِ `SecretaryPermissionChecker`. «خالیِ خاموش»
برخلاف پیشنهاد ۳ گزارش، پرسنل در DB زنده ساخته **نشد**: دادهٔ دستی با اولین re-seed میرود و
هیچوقت خودکار اجرا نمیشود.
+## یافتهٔ ۱۱ 🟧 HIGH — `$branches` تزریقنشده: اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰
+
+**۱. فایل:** `src/Appointment/Controller/MyAppointmentsController.php:384`
+
+**۲. شرح:** کنترلر `$this->branches->pair($user)` را صدا میزد ولی `AddressResolver` هرگز در
+constructor نبود. هر `POST /api/v1/my/appointment` که `treatment_session_uuid` داشت روی
+«Undefined property» میافتاد. یعنی اتصال نوبت به جلسهٔ درمان از پنل **هیچوقت کار نکرده**.
+
+**۳. چرا کسی ندید:** این شاخه هیچ تستی نداشت. `phpstan` دقیقاً همین را گزارش میکرد، ولی بین
+۱۶ خطای بیاثرِ دیگر گم شده بود — «۱۷ خطا» عددی ثابت شده بود که همه ازش رد میشدند. درسش این
+است که baselineِ نخوانده، باگ زنده را پنهان میکند.
+
+**۴. رفع:** تزریق `AddressResolver $branches` — همان سرویسی که `AppointmentPlanController` و
+`TreatmentProtocolController` با همین نام استفاده میکنند.
+
+**۵. تست:** `tests/Appointment/AppointmentTreatmentSessionLinkTest.php` — سه سناریو: بدون
+اتصال (۲۰۱)، uuidِ ناموجود (۴۰۴ با `treatment_session_uuid` در فیلد خطا، نه ۵۰۰)، و رشتهٔ
+خالی (۲۰۱).
+
+## یافتهٔ ۱۲ 🟦 LOW — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵
+
+**۱. شرح:** سیاست `html_sanitizer.yaml` برای `` هیچ attributeی مجاز نکرده بود. از آنجا
+که پاکسازی در **لحظهٔ ذخیره** است، هر مقالهای که از پنل ویرایش میشد حاشیه و فاصلهٔ جدولش را
+از دست میداد. رگرسیون از ۲۰۲۶-۰۸-۰۷ فعال بود و کسی ندیده بود، چون هنوز کسی مقالهٔ جدولدار را
+ویرایش نکرده بود.
+
+**۲. رفع:** `border`, `cellpadding`, `cellspacing` مجاز شدند — هر سه عددی و غیرقابلاجرا.
+`style` عمداً ممنوع ماند و ظاهرِ از دست رفته (`border-collapse`, `width`) در `.blog-body`
+داخل `assets/admin/styles.css` بازسازی شد؛ یعنی presentation به لایهٔ درستش رفت.
+
+**۳. تست:** `BlogBodySanitizerTest::testTableKeepsInertLayoutAttributesButLosesStyle`.
+
+**۴. کارِ باقیمانده در repo دیگر:** `nobat724_front` همان بدنه را رندر میکند و قاعدهٔ CSS
+معادل را ندارد. جدولِ مقالهها آنجا بدون `border-collapse` نمایش داده میشود.
+
+## یافتهٔ ۴ — بسته شد: مهاجرت CKEditor
+
+`@ckeditor/ckeditor5-build-classic@44.3.0` (deprecated، ۶۲ advisory) با پکیج umbrella
+`ckeditor5@48.4.0` جایگزین شد. `@ckeditor/ckeditor5-react@11.2.0` از قبل نصب بود و
+peer dependency اش `ckeditor5 >= 46` است، پس bump دیگری لازم نشد.
+
+مهاجرت کمریسک بود چون سطح مصرف کوچک است: دو صفحه، یک `ClassicEditor`، ده دکمهٔ نوار ابزار.
+پیکربندی در کامپوننت مشترک `assets/admin/components/RichTextEditor.tsx` متمرکز شد تا مهاجرت
+بعدی یک فایل باشد و نوار ابزارِ دو صفحه از هم واگرا نشود.
+
+در پکیج umbrella، برخلاف buildِ آماده، فهرست پلاگینها باید صریح باشد. عمداً دقیقاً همان
+پلاگینهای دکمههای قبلی آورده شد و نه بیشتر: هر پلاگین اضافه یعنی markup تازهای که
+`html_sanitizer.yaml` مجازش نکرده و هنگام ذخیره حذف میشود.
+
+```
+npm audit --omit=dev # قبل: 61 (moderate=3, low=58) → بعد: 0
+npx tsc --noEmit # بدون خطا
+ddev exec yarn dev # webpack compiled successfully — 54 فایل
+npx vitest --run # ۸۰۱ تست فرانتاند سبز
+```
+
+## یافتهٔ ۱۰ — نیمهرفع: ۲۲ روت از ۳۴ بسته شد
+
+چکِ اصلیِ این روتها شیءمحور است و بالا نمیرود — `ClinicController::update` به `$clinic`ِ همان
+رکورد نیاز دارد. ولی **سهمِ منشی** از آن چک همیشه همان توگلِ رجیستری است، پس یک پیشچکِ
+فقط-منشی اکیداً ضعیفتر است: هر کسی را که رد کند، چکِ پایینتر هم رد میکرد. یعنی هیچ مسیرِ
+مجازی بسته نمیشود و فقط ۴۰۴ به ۴۰۳ تبدیل میشود.
+
+`ClinicDoctorAccessChecker` عمداً در پیشچک نیست: پزشکِ عضو ممکن است روی رکوردِ کلینیکِ دیگری
+که مالکش است اقدام کند، و آنجا محیطِ فعال با محیطِ رکورد یکی نیست. آن حالت را فقط چکِ
+شیءمحورِ پایین میتواند درست بسنجد.
+
+بسته شد (۲۲): `AppointmentController` × ۴ · `AppointmentSettingsController` × ۹ ·
+`ClinicController::update`, `detachDoctor` · `ClinicDoctorPermissionController` ×۱ ·
+`ClinicInvitationController` × ۴ · `ResourceBlockController` × ۲.
+
+باز ماند (۱۲) — هیچکدام منبعی در `PermissionCatalog` ندارند و چکشان مالکیتِ خودِ رکورد است:
+
+- `ClinicController` — `createAddress`, `updateAddress`, `deleteAddress`. `addresses` فقط
+ `view` دارد؛ نوشتنِ آدرس صریحاً owner-or-admin است و اصلاً قابل واگذاری نیست.
+- `DoctorController` — `update`, `delete`, `updateAddress`, `deleteAddress`.
+- `RepresentationController::update`.
+- `SecretaryController` — `create`, `update`, `deactivate`, `syncClinicDoctors`.
+
+پوششِ نشتِ باقیمانده کمارزش است: uuidِ پزشک از فهرست عمومی پزشکان در دسترس است. برای منشی و
+نماینده ارزشش بیشتر است ولی همچنان Low. بستنشان یا کلیدِ تازه در رجیستری میخواهد — که UI
+مجوزها و دو Entity و تایپهای فرانت را درگیر میکند — یا یک مکانیزم موازیِ نقشمحور، که یافتهٔ ۱
+توصیه کرد نسازیم.
+
+`ApiLeastPrivilegeTest::testHoistedGatesAnswer403BeforeTheLookup` این ۲۲ تا را قفل میکند: اگر
+کسی خطِ پیشچک را بردارد، پاسخ به ۴۰۴ برمیگردد و تست قرمز میشود.
+
+## پاکسازی `phpstan` — و آنچه زیرش پنهان بود
+
+هر ۱۷ خطای بیرون از baseline بسته شد و `phpstan` حالا `No errors` میدهد. baseline از ۴۴ به
+۴۳ ردیف رسید. جنس خطاها:
+
+- **۱ باگ زنده** — یافتهٔ ۱۱ بالا.
+- **۳ ناهمخوانی نوع** — `BillingController` فیلترهای `from`/`to` را string میفرستاد و
+ `InvoiceService` عددِ صحیح میخواست. تبدیل در مرزِ ورودی نشست، نه در repository.
+- **۶ property تزریقشده و بلااستفاده** در پنج سرویس — حذف شدند.
+- **۲ فراخوانیِ `getEntityManager()` از بیرون** در `SecretaryService` — با `EntityManagerInterface`
+ تزریقشده جایگزین شد.
+- **۲ ignore pattern کهنه** که دیگر با هیچ خطایی مطابقت نداشتند.
+- **۳ مقایسهٔ همیشه-درست** — ساده شدند.
+
+دو مورد عمداً با `@phpstan-ignore-next-line` ماندند: `ServiceItem::getConsumables()` و
+`getStaffMembers()`. `phpstan` فقط constructor را میبیند و میگوید property همیشه مقدار دارد؛
+Doctrine اما بدون constructor هیدریت میکند. گاردِ `??=` عمدی است.
+
+## پاکسازی مقالههای قدیمی
+
+`app:blog:sanitize-bodies` ساخته شد و روی هر ۴۲۷ مقاله اجرا شد. دستور است نه migration، چون
+سیاست ممکن است دوباره سفت شود و آنوقت باید همین گذر تکرار شود.
+
+عددِ خام گمراهکننده بود: «۴۲۶ مقاله تغییر میکند» در نگاه اول یعنی ۴۲۶ مقالهٔ آلوده. تفکیکِ
+جنسِ تغییر نشان داد:
+
+| جنس تغییر | تعداد | یعنی چه |
+|---|---|---|
+| سختسازی | ۳۹۸ | افزودن `rel="noopener noreferrer"` یا decode شدن ` ` |
+| حذف attribute | ۲۸ | فقط `style` روی ۲۲ جدول و ۱ `div`؛ بقیه ترمیمِ HTML شکسته |
+| بدون تغییر | ۱ | — |
+
+**هیچ مقالهای markup اجرایی نداشت** — نه `