# Plan — Platform Omurgası (index)

*EGT-21 · Sürüm 2.8 · 27.07.2026*

**Upstream (kilitli):** `docs/specs/omurga-spec.md` **v1.11** — üç bağımsız kimlik alanı (ofis / backoffice / müşteri), **`review-omurga-spec-2026-07-26-r2.md` turunda APPROVED**. Öncesi: v1.7 onaylı (r8), v1.8 REJECTED, v1.9/v1.9.1 onun cevabı ve r4 turunda APPROVED.

⚠ **Bu iki satır her plan revizyonunda güncellenir.** Üç kez üst üste eski kaldı (heyet 147 F-12 ve öncesi R4-1): sürüm damgası 2.5'te, upstream v1.10'da donmuştu — oysa revizyon geçmişi 2.7'ye, onaylı upstream v1.11'e gelmişti. *"Plan kanundur"* rejiminde index'in kendisi yanlış sürümü gösteriyorsa, kanun hangi metin olduğu belirsizdir.
**Mimari dayanak:** `docs/arch/mimari-kararlar.md` AD-01…AD-28 · `docs/arch/kod-yerlesimi.md` · `docs/arch/paket-surumleri.md`

---

## Bu plan nasıl okunur

**Plan kanundur.** Implementasyonu başka bir session yapacak; bu doküman dışına çıkılmaz, eksik gördüğü yeri kendi kafasına göre tamamlamaz. Bir şey burada yazmıyorsa yapılmaz; belirsizse **durulur ve sorulur** (`docs/issues.md`'ye yazılır).

Plan **tek dosyadır** ve chunk'lar onun bölümleridir. **Bütün olarak** review edilir; düzeltme ve implementasyon chunk bazında yürür.

⚠ **Neden tek dosya (Sets kararı, 27.07.2026).** Plan 25.07'de dokuz ayrı chunk dosyası olarak yazıldı — Sets bölmemek gerektiğini baştan söylemişti, bölen bendim. Faturası her turda çıktı: chunk2'nin *"kesin listesi"* chunk5'in tablolarını bilmiyordu, `issues.md` bir işi üretmeyen chunk'a atfetti, index'in sürüm damgası üç kez eskidi, kriterler dosyalar arasında göç etti ve her göç çok dosyalı bir cerrahi oldu. **Bu hataların tamamı tek dosyada yazarken görünürdü.** Karşılaştırma elimizde duruyordu: spec baştan beri tek dosya ve orada bu sınıf hata hiç çıkmadı — spec'in sorunları iç çelişkiydi, *"öbür dosya bilmiyor"* değil.

## Dil kuralı (CLAUDE.md — mutlak)

**Kodun tamamı İngilizce:** tablo ve kolon adları, sınıf/metot/değişken adları, dosya ve route adları, izin adları, enum değerleri, kod içi yorumlar, PHPDoc, test adları, migration adları, config anahtarları, log mesajları.

**Arayüzün tamamı Türkçe** ve `lang/` üzerinden gelir; koda gömülü kullanıcı metni **yasaktır** (arch testiyle zorlanır). Uygulama çok dillidir: `tr` varsayılan, `en` hazır set.

Bu doküman Türkçedir (karar metnidir); ürettiği kod İngilizcedir.

## Chunk haritası

| Chunk | Kapsam | Bağımlılık |
|---|---|---|
| [Chunk 1](#chunk-1) | **Temel yapı** — dizin iskeleti, config, host ayrımı, çok dillilik temeli, arch testleri, test altyapısı, CI | — |
| [Chunk 2](#chunk-2) | **Tenancy + RLS + tenant context** — DB rolleri, kapsam listesi modeli, policy deseni, `TenantContext` iş birimi, auth bootstrap kapısı | chunk1 |
| [Chunk 3](#chunk-3) | **Audit** — üç log sınıfı, append-only trigger, maskeleme, TimescaleDB, damgalama, WORM, analitik toplama | chunk1, chunk2 |
| [Chunk 4](#chunk-4) | **Hassas veri** — alan şifreleme, tenant anahtarı, dosya saklama ve erişimi, rıza kaydı | chunk2, chunk3 |
| [Chunk 5](#chunk-5) | **Kimlik ve kimlik doğrulama** — **üç** kimlik alanı, katmanlı yetenek, dört yöntem, MFA, magic link, oturum | chunk1, chunk2, chunk3 |
| [Chunk 6](#chunk-6) | **Yetki zorlama + görünürlük + referans kodları** | chunk2, chunk3, chunk5 |
| [Chunk 7](#chunk-7) | **Bildirim** — Postmark, İletiMerkezi, Discord, uygulama içi, İYS kapısı, kademeli uyarılar, şablon çevirisi | chunk1, chunk3, chunk6 |
| [Chunk 8](#chunk-8) | **Realtime + analitik ekranları** — Reverb, TTL'li çalışıyor göstergesi, k-anonim analitik | chunk3, chunk5, chunk6 |
| [Chunk 9](#chunk-9) | **Test altyapısı, kural taraması, rollout, rollback** | tümü |
| [Chunk 10](#chunk-10) | **Backoffice sistem verisi** — mevzuat kaydı, eğitim kataloğu, parametre/limitler, evrak türleri, auth yöntem ayarları, bildirim şablonları, rıza metinleri, ofis provisioning. Filament, tasarımcıya bağlı değil | chunk4, chunk5, chunk6 |

**Uygulama sırası: 1 → 2 → 3 → 4 → 5 → 6 → 10 → 7 → 8 → 9** (Sets kararı, 27.07.2026). Numara sırası değil; chunk 10 sonradan eklendiği için numarası sonda, yeri ortada.

⚠ **Sıra neden değişti.** Chunk 1–6 saf altyapıdır ve altısı da kendi kullanıcısını üretmez: chunk 6 tek başına altı tane çağıranı olmayan mekanizma doğurdu (`CreateCustomer`, `ReferenceCodes`, `IssueDocumentLink`, `VisibilityRule`, `FindByReferenceCode`, `office_user_customers`'a yazan hiç kimse). Bu bir kusur değil — backbone'un tanımı — ama **üst üste binmesi** her turda "şu olmadığı için bu yapılamıyor" üretmeye başladı ve mekanizmaların kullanılabilirliği hiçbir zaman sınanmadı; yalnız doğrulukları, üstelik mekanizmayı yazanın testleriyle.

Chunk 10 bunu kırar, çünkü Filament **tasarım handoff'una bağlı değildir** — kendi arayüzünü üretir. Yani paralel gidilebilecek tek UI parçasıdır. Ondan sonrası ofis panelinin adım adım ayağa kalkmasıdır ve o noktadan itibaren kural **tablo + kural + ekran aynı turda**.

⚠ Chunk 7 ve 8 de bunu bekliyordu: bildirim altyapısının şablonu (`notification_templates`), realtime'ın üzerinde konuşacağı iş olayları chunk 10'un ürettiği veriden doğar. Sıra değişikliği onları da geciktirmiyor, besliyor.

## Kanıtlanmış varsayımlar

Plan yazılırken canlı PostgreSQL 17 + PgBouncer üzerinde denendi:

| Varsayım | Sonuç |
|---|---|
| **Superuser `FORCE ROW LEVEL SECURITY`'yi de bypass eder** | ⚠ Doğrulandı — uygulama superuser bağlanırsa izolasyon **sessizce** kalkar |
| Non-superuser rol RLS'e tabi | ✅ |
| Bağlamsız sorgu temiz hata veriyor | ✅ `tenant context not set` |
| Çapraz tenant INSERT `WITH CHECK` ile engelleniyor | ✅ |
| `SET LOCAL` transaction sonunda **boş string** bırakıyor (NULL değil) | ✅ Kontrol ikisini de kapsamalı |
| PgBouncer transaction pooling'de sızıntı yok | ✅ 6 ardışık havuzlanmış bağlantı temiz |
| PgBouncer `userlist.txt` uygulama rolünü içermeli | ⚠ İçermezse uygulama superuser'a düşer |

Detay ve SQL: chunk2 §2.1.

## v1.0 plan review bulgularının kapanışı

`docs/reviews/review-omurga-plan-2026-07-25.md` — 4 majör + 5 minör.

| Bulgu | Nerede kapandı |
|---|---|
| **P-1** Ofis çok-tenant erişimi ve agregat okuma karşılıksız | Spec v1.5 ofis kimliğini tenant dünyasından çıkardı; chunk2 §2.4 **kapsam listesi** modeli + §2.6 agregat okuma kapısı |
| **P-2** Auth öncesi bağlam bootstrap'i tanımsız, login çalışmıyor | Spec v1.6 §4.1 istisnayı tanımladı; chunk2 §2.7 **tek kapı** (`authenticate_lookup`) |
| **P-3** Bağlam kancasının transaction yaşam döngüsü tanımsız | chunk2 §2.5 **`TenantContext::run()` açık iş birimi**; kanca yalnız *bekçi* |
| **P-4** chunk5'in kriterleri sonraki chunk'lara dayanıyordu | Kriterler ilgili chunk'lara taşındı (chunk5 §5.9, chunk6 §6.8, chunk7 §7.9) |
| **P-5** `users` RLS istisnasının `WITH CHECK` tarafı geniş | Yapısal olarak yok: `users` artık tenant tablosu değil (chunk5 §5.1) |
| **P-6** Append-only `DO INSTEAD NOTHING` sessiz | chunk3 §3.2 **trigger + `RAISE EXCEPTION`** |
| **P-7** Geçici ScopeResolver borcu sıralama kısıtsız | chunk6 §6.4 **prod çıkış kısıtı** yazıldı |
| **P-8** unaccent arama ve tatil takvimi karşılıksız | chunk1 §1.9 (arama), chunk7 §7.7 (takvim varsayımı) |
| **P-9** chunk8 bağımlılığı ve audit tablolarının RLS durumu | Bağımlılık tablosu düzeltildi; audit RLS chunk3 §3.6 |

## Chunk 1 — Temel yapı, çok dillilik, arch testleri, CI


**Bağımlılık:** yok.
**Bitiş şartı:** §1.11 kabul kriterleri.

---

### 1.1 Dizin iskeleti

`docs/arch/kod-yerlesimi.md` sözleşmesinin fiziksel karşılığı:

```
app/
├── Domain/              # framework'süz iş kuralı kararları
│   ├── Shared/          # ReferenceCode gibi omurga saf sınıfları
│   ├── Authorization/   # ScopeResolver arayüzü (chunk6)
│   └── Visibility/      # görünürlük kuralları (chunk6)
├── Actions/             # tek eylem, tek __invoke
├── Queries/             # sorgular
├── Policies/
├── Integrations/        # dış servis adaptörleri
├── Data/                # DTO'lar
├── Models/
├── Http/{Controllers,Middleware,Requests}/
├── Jobs/
├── Listeners/
├── Support/             # framework'e bağlı, karar vermeyen yardımcılar
└── Providers/
```

`app/Support/` sözleşmede yoktu. **Bu chunk'ta `docs/arch/kod-yerlesimi.md`'ye eklenir:** *"framework'e bağlı, iş kuralı kararı vermeyen yardımcılar. Karar veren kod buraya konmaz — o `app/Domain/`'e aittir."*

### 1.2 Config dosyaları

Kodda `env()` yalnız `config/` altında kullanılır (kırmızı çizgi 7).

| Dosya | İçerik |
|---|---|
| `config/tenancy.php` | kapsam anahtarı adı, DB rol adları, bağlam gerektirmeyen tablo listesi |
| `config/audit.php` | maskelenecek alan adları, hassas alan listesi, damgalama aralığı, kullanım logu saklama varsayılanı (730 gün) |
| `config/reference_codes.php` | prefix registry (`EG`, `TK`, `FT`), alfabe, uzunluk |
| `config/notifications.php` | kanal ayarları, Discord webhook eşlemesi, İYS ayarları, hatırlatma kademeleri |
| `config/security.php` | auth yöntem varsayılanları, MFA zorunlu izin listesi, oturum süreleri, giriş deneme eşikleri, dosya bağlantı ömrü |
| `config/locales.php` | desteklenen diller, varsayılan dil, çevrilebilir alan tanımları |

`config/app.php`: `timezone` = **`UTC`**, `display_timezone` = `Europe/Istanbul`, `locale` = `tr`, `fallback_locale` = `tr`, `faker_locale` = `tr_TR`.

> **Düzeltme (Sets, 25.07.2026).** Bu satır önce `timezone = Europe/Istanbul` diyordu ve §1.5'in "kayıtlar her zaman UTC saklanır" kuralıyla çelişiyordu: Laravel'de `app.timezone` **saklama** saat dilimidir, gösterim değil. Kural nettir — **veritabanı UTC, gösterim her zaman Türkiye.** UTC saklamak, ileride saat dilimi ayarı yapıldığında rahat etmek içindir. Gösterim `display_timezone` ve `DisplayTimezone` üzerinden çözülür; tenant kendi saat dilimini taşır (spec §12) ve taşımadığı sürece herkes Türkiye saatini görür.

### 1.3 Host ayrımı

| Host | Panel | Route dosyası |
|---|---|---|
| `egitim.dev.sets.fyi` / `akademi.3egrp.com` | Landing + müşteri `/panel` + katılımcı `/katilimci` | `routes/web.php` |
| `ofis.egitim.dev.sets.fyi` / `ofis.egitim.3egrp.com` | Ofis | `routes/office.php` |
| `backoffice.*` | Filament | Filament panel provider |

`bootstrap/app.php` içinde `withRouting(...)` genişletilir; `routes/office.php` **domain kısıtıyla** bağlanır, host değeri config'ten okunur.

**Host ayrımı güvenlik sınırı değildir** — yetki her zaman policy'den gelir (chunk6). Host ayrımı yönlendirme ve oturum çerezi kapsamı içindir.

### 1.4 Çok dillilik temeli

Spec §12. **Bu chunk mekanizmayı kurar; içerik ilgili chunk'larda gelir.**

**Arayüz metinleri:**
- `lang/tr/` ve `lang/en/` dizinleri. `tr` varsayılan ve tam; `en` aynı anahtarlara sahip.
- **Koda gömülü kullanıcı metni yasak** — arch testiyle zorlanır (§1.7).
- Anahtar adlandırması İngilizce ve nokta ayrımlı: `auth.login.failed`, `documents.upload.size_exceeded`.

**Dil çözümleme zinciri** (`app/Http/Middleware/SetLocale.php`):
1. Kullanıcının kayıtlı tercihi
2. Tenant varsayılanı (müşteri tarafında; ofis tarafında atlanır)
3. Uygulama varsayılanı (`tr`)

Zincir **her adımda düşer**; eksik çeviri boş ekran üretmez — Laravel'in fallback'i `tr`'ye iner.

**Admin-tanımlı çevrilebilir içerik:**
- Çevrilebilir alanlar PostgreSQL `jsonb` kolonda dil anahtarlı saklanır: `{"tr": "...", "en": "..."}`. Ek paket kullanılmaz.
- Hangi alanın çevrilebilir olduğu **alan bazında** `config('locales.translatable')` içinde tanımlıdır; her alan çevrilebilir değildir.
- `app/Support/Locale/TranslatableCast.php` — cast; okuma sırasında dil zincirini uygular.
- Eksik çeviri raporu backoffice'te (chunk7'de şablonlar, ilgili chunk'larda diğerleri).

**Çevrilmeyenler:** mevzuat metinleri (spec §12) ve resmî belgeler. Bu bir kural değil bir yasaktır: çevrilebilir alan tanımına mevzuat alanları **konmaz**.

### 1.5 Zaman ve biçim

- Kayıtlar **her zaman UTC** saklanır.
- Gösterim saat dilimi: müşteri tarafında tenant ayarı, ofis tarafında uygulama varsayılanı (`Europe/Istanbul`).
- `app/Support/Time/DisplayTimezone.php` — aktif kimliğe göre gösterim saat dilimini çözer.
- Tarih `d.m.Y`, saat 24 saat, para binlik ayraçlı — biçimler `lang/` üzerinden, koda gömülmez.

### 1.6 Test altyapısı

`tests/Pest.php` grup atamaları (AD-26):

```
omurga · omurga-rls · backoffice · planlama · satis · egitmen · musteri · katilimci · entegrasyon · mevzuat
```

Bu chunk'ta `omurga` ve `omurga-rls` kullanılır; diğerleri boş dizinleriyle hazır bekler.

**Zorunlu global ayarlar** (`phpunit.xml`, hepsi `<server ... force="true"/>`):

| Ayar | Değer | Gerekçe |
|---|---|---|
| `CACHE_STORE` | `array` | Paylaşılan state olmasın |
| `SESSION_DRIVER` | `array` | |
| `QUEUE_CONNECTION` | `sync` | Kuyruk davranışı test edilecekse `Queue::fake()` |
| `BROADCAST_CONNECTION` | `null` | Realtime testte susar |
| `MAIL_MAILER` | `array` | |
| `BCRYPT_ROUNDS` | `4` | Hız |
| `DB_USERNAME` | `3eegitim_test` | **Superuser değil** (chunk2) |

`force="true"` olmadan `.env` bu değerleri ezer.

`tests/Pest.php` global `beforeEach`: `Http::preventStrayRequests()` ve `Carbon::setTestNow()` sabit ana. `afterEach`: `Carbon::setTestNow(null)`.

**Config cache guard:** `tests/TestCase.php::setUp()` içinde `bootstrap/cache/config.php` varsa `RuntimeException`. Cache'lenmiş config test ortam değişkenlerini yok sayar.

### 1.7 Arch testleri

`tests/Arch/`, hepsi `omurga` grubunda.

| Dosya | Kural |
|---|---|
| `LayerTest.php` | `App\Domain\*` içinde `Illuminate\*`, `App\Models\*`, Facade import edilemez |
| `LayerTest.php` | `App\Http\Controllers\*` içinde `DB::`, query builder, `validate()` yok |
| `LayerTest.php` | `App\Models\*` içinde HTTP çağrısı, `Notification`, `Mail` yok |
| `LayerTest.php` | `App\Actions\*` tek public method (`__invoke`) expose eder |
| `LayerTest.php` | `App\Integrations\*` dışında sağlayıcı SDK sınıfı import edilmez |
| `EnvTest.php` | `config/` dışında `env(` yok |
| `LocaleTest.php` | Blade/Vue/PHP içinde koda gömülü kullanıcı metni yok — kullanıcıya görünen metin `__()` / `trans()` üzerinden gelir |
| `LocaleTest.php` | `lang/tr` ve `lang/en` **aynı anahtar kümesine** sahip (eksik anahtar testi kırar) |
| `TestDisciplineTest.php` | `tests/` içinde çıplak `now()` / `Carbon::now()` yok |
| `TestDisciplineTest.php` | Her test dosyası tanımlı gruplardan birine ait |

**Arch testlerinin kendisi kanıtlanır:** her kural için kuralı ihlal eden geçici bir dosya oluşturulur, testin kırıldığı görülür, dosya silinir. Bu adım atlanamaz — çalışmayan arch testi yanlış güven verir.

### 1.8 Statik analiz

`larastan/larastan` v3.10 kurulu. `phpstan.neon`: level 6 başlangıç, `app/` ve `tests/` dahil. CI'da koşar, hata build'i kırar.

### 1.9 Türkçe arama (unaccent)

Spec §12: aksan ve büyük/küçük harf farkı arama sonucunu değiştirmez. `unaccent` extension kurulu ve doğrulanmış.

- `app/Support/Database/SearchExpression.php` — arama ifadesi üretir: `unaccent(lower(kolon)) LIKE unaccent(lower(?))`.
- Arama yapılan metin kolonlarında **fonksiyonel index**: `CREATE INDEX ... ON t (unaccent(lower(col)) text_pattern_ops)`.
- ⚠ `unaccent` varsayılan olarak `IMMUTABLE` **değildir**; fonksiyonel index için `IMMUTABLE` sarmalayıcı gerekir. Migration bu sarmalayıcıyı `app` şemasında oluşturur:
  `CREATE FUNCTION app.immutable_unaccent(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ SELECT public.unaccent('public.unaccent', $1) $$;`
- Bu chunk'ta yardımcı ve fonksiyon kurulur; hangi kolonların indeksleneceği ilgili chunk/dikey planlarda.

### 1.10 CI

`composer.json` scripts:

```
test          → pest --parallel
test:omurga   → pest --group=omurga --parallel
test:rls      → pest --group=omurga-rls          (parallel YOK — chunk2 §2.9)
lint          → pint --dirty
analyse       → phpstan analyse
```

TeamCity adımı: `composer install` → `pint --test` → `phpstan analyse` → `pest --parallel --log-teamcity`.

### 1.11 Kabul kriterleri

1. Dizin iskeleti kurulu; `docs/arch/kod-yerlesimi.md`'ye `app/Support/` satırı eklenmiş.
2. Altı config dosyası mevcut; `config/` dışında `env(` yok (arch testi, kasıtlı ihlalle doğrulanmış).
3. Üç host doğru route dosyasına gidiyor; host değerleri config'ten okunuyor.
4. `lang/tr` ve `lang/en` aynı anahtar kümesine sahip; eksik anahtar arch testini kırıyor.
5. Koda gömülü kullanıcı metni arch testini kırıyor (kasıtlı ihlalle doğrulanmış).
6. Dil çözümleme zinciri çalışıyor: kullanıcı tercihi → tenant → uygulama varsayılanı (test).
7. `jsonb` çevrilebilir alan cast'i dil zincirini uyguluyor; eksik dilde fallback dönüyor (test).
8. Kayıtlar UTC saklanıyor; gösterim saat dilimi kimliğe göre çözülüyor (test).
9. On arch kuralının her biri kasıtlı ihlalle kırıldığı gösterilerek doğrulanmış.
10. `phpunit.xml`'de tüm test ortam değişkenleri `force="true"`; `DB_USERNAME` test rolü.
11. Config cache guard çalışıyor.
12. `Carbon::setTestNow()` global; çıplak `now()` arch testiyle yasak.
13. `app.immutable_unaccent` fonksiyonu kurulu; aksan duyarsız arama ifadesi çalışıyor (test: "İstanbul" araması "istanbul" ve "Istanbul" kayıtlarını buluyor).
14. PHPStan level 6 temiz.
15. TeamCity build'i test fazıyla yeşil.

---

## Chunk 2 — Tenancy, RLS ve tenant context


**Bağımlılık:** chunk1
**Spec dayanağı:** §3, §3.0, §3.1, §3.2, §4 (4.1 B-1…B-6, T-1…T-7, V-1…V-5, I-1…I-4)
**Mimari dayanak:** AD-02, AD-03, AD-04
**Bitiş şartı:** §2.11 kabul kriterleri.

> Bu chunk ürünün 1 numaralı kırmızı çizgisini kurar. Hiçbir adım ertelenemez.

> **Kapsam düzeltmesi (Sets onayı, 26.07.2026).** Bu chunk'ın 7, 9c ve 11 numaralı kriterleri `customers`, `customer_users` ve `magic_links` tablolarını gerektiriyordu; oysa bu üç tablonun **tanımı chunk5 §5.1'dedir** (kolonlar, `(tenant_id, email)` unique kısıtı, `token_hash`) ve chunk2 yalnız chunk1'e bağımlıdır. Tabloları burada minimal açıp chunk5'te genişletmek aynı tabloyu iki kez açmak demekti.
>
> **Karar:** chunk2 **mekanizmayı** eksiksiz teslim eder ve fixture tabloyla kanıtlar; üç tabloya bağlı kriterler **chunk5'e taşınır**. `TenantPolicy::applyToCustomers()` ve iki `SECURITY DEFINER` kapısının şablonu bu chunk'ta yazılır ve hazır durur — chunk5 tabloları oluşturduğunda tek satırla uygular.
>
> | Taşınan | Nereye |
> |---|---|
> | Kriter 7 — `customers` id bazlı politika | chunk5 |
> | Kriter 9c — `app.all_customer_ids()` ve `app.magic_link_lookup()` | chunk5 |
> | Kriter 11 — `authenticate_lookup` dört alan; `customer_users` RLS'li | chunk5 |
> | Kriter 14 — `customer_users` global unique index yok | chunk5 |
> | Test R-12, R-14, R-16 | chunk5 |
>
> Chunk2'de kalan her şey (roller, bağlam fonksiyonları, `TenantPolicy`, `TenantContext`, bekçi, middleware, `BelongsToTenant`, mali rol ayrımı, R-1…R-11b, R-13, R-15, R-17) değişmedi.


---

### 2.1 Kanıtlanmış davranış

Canlı PostgreSQL + PgBouncer üzerinde denendi. Implementasyon bunları yeniden keşfetmez.

> **PostgreSQL 18.4'te yeniden doğrulandı (26.07.2026).** Deneyler ilk olarak PG 17.10'da yapılmıştı; major yükseltmeden sonra hepsi **aynı ortamda yeniden koşuldu**, çünkü bu tablonun tamamı RLS ve oturum değişkeni davranışına dayanıyor ve bir major sürüm bunları sessizce değiştirebilirdi. **Sonuç: sapma yok** — altı ortam deneyi (1, 6, 7, 10, 11, 12) ve chunk3'ün iki sapması PG 18.4'te birebir aynı davrandı. Superuser hâlâ `FORCE ROW LEVEL SECURITY`'yi bypass ediyor; `SET LOCAL` COMMIT sonrası hâlâ **boş string** bırakıyor (NULL değil); politikasız UPDATE hâlâ sessizce 0 satır dönüp trigger'ı ateşlemiyor; iki permissive politikanın ikisi de değerlendiriliyor (OR kısa devresi yok); RLS'li hypertable üzerinde columnstore ve continuous aggregate hâlâ kurulamıyor. Tablodaki hiçbir satır değişmedi.

| # | Deney | Sonuç |
|---|---|---|
| 1 | Superuser (`postgres`), `FORCE ROW LEVEL SECURITY` açık tablo, bağlam=1 | **Tüm satırlar döndü — RLS bypass edildi** |
| 2 | Non-superuser rol, bağlam=1 | Yalnız tenant 1 satırı |
| 3 | Non-superuser rol, bağlam=2 | Yalnız tenant 2 satırı |
| 4 | Bağlamsız sorgu | `ERROR: tenant context not set` |
| 5 | Bağlam=1 iken `tenant_id=2` INSERT | `ERROR: new row violates row-level security policy` |
| 6 | `COMMIT` sonrası `current_setting(..., true)` | **Boş string** (`''`), NULL değil |
| 7 | PgBouncer transaction pooling, 6 ardışık bağlantı | Transaction içinde doğru, sonrasında boş — **sızıntı yok** |
| 8 | Tek politika (`USING` liste + `WITH CHECK` tek tenant), çok-tenant kapsamla `DELETE` | **Silme GERÇEKLEŞTİ — `WITH CHECK` DELETE'i korumuyor** |
| 9 | Komut bazlı dört politika, çok-tenant kapsamla `DELETE` | `ERROR: write needs single tenant` — engellendi |
| 10 | RLS açık tabloda **UPDATE politikası yokken** `UPDATE` | **Sessizce 0 satır — BEFORE trigger hiç ateşlenmedi** |
| 11 | Aynı komut için iki ayrı permissive `WITH CHECK` politikası (biri `write_tenant_id()` çağırıyor) | **Her ikisi de değerlendirildi → fırlatan fonksiyon patladı.** OR kısa devresine güvenilemez |
| 12 | Tek politika + `write_tenant_id_or_null()` | Bağlamsız NULL geçti · tek-tenant geçti · çok-tenant engellendi · çapraz engellendi |

**Deney 1 bu chunk'ın en kritik bulgusudur.** `FORCE ROW LEVEL SECURITY` superuser'ı bağlamaz. Uygulama superuser bağlanırsa izolasyon **sessizce** yok olur: hata vermez, test yeşil görünür, veri sızar.

**Deney 6'nın sonucu:** bağlam kontrolü `NULL` **ve** `''` durumlarının ikisini birden kapsamalıdır.

### 2.2 Veritabanı rolleri

**Uygulama hiçbir ortamda superuser bağlanmaz.**

| Rol | Superuser | Kullanım |
|---|---|---|
| `postgres` | evet | Yalnız migration, bakım, dump. Runtime **kullanmaz**. |
| `3eegitim_app` | hayır | Uygulamanın runtime rolü (web, queue, console). |
| `3eegitim_app_financial` | hayır | `3eegitim_app`'in mali tablolara da erişebilen sürümü (§2.10). |
| `3eegitim_test` | hayır | Test rolü, `3eegitim_app` ile aynı yetki profili. |

Roller **migration ile** oluşturulur (elle DDL yasak — kırmızı çizgi 6): `0001_01_01_000100_create_application_roles.php`

- `CREATE ROLE ... LOGIN` (varsa atlar). Şifreler config'ten gelir; migration'da sabit şifre **yazılmaz**.
- `GRANT CONNECT`, `GRANT USAGE ON SCHEMA public, app`, tablo düzeyinde `SELECT, INSERT, UPDATE, DELETE`.
- `ALTER DEFAULT PRIVILEGES` — sonradan eklenen tablolarda grant otomatik verilsin; aksi halde her yeni tabloda unutulur.
- `down()` rolleri düşürmez (başka veritabanları kullanıyor olabilir), yalnız grant'ları geri alır.

**PgBouncer:** `deployment/pgbouncer/userlist.txt` **`3eegitim_app` ve `3eegitim_app_financial` rollerini içermek zorundadır.** İçermezse uygulama bağlanamaz; "çalışsın diye" `postgres`'e düşürmek deney 1'deki sessiz izolasyon kaybını üretir. `userlist.txt.example` ve `README.md` bu uyarıyla güncellenir.

**İki bağlantı tanımı** (`config/database.php`):

| Bağlantı | Port | Rol | Kullanım |
|---|---|---|---|
| `pgsql` | `DB_PORT` (6433, PgBouncer) | `3eegitim_app` | Uygulamanın tamamı |
| `pgsql_financial` | `DB_PORT` (6433, PgBouncer) | `3eegitim_app_financial` | Mali tablolara erişim gereken istekler (§2.10) |
| `pgsql_admin` | `DB_DIRECT_PORT` (5534, doğrudan) | `postgres` | Yalnız migration ve bakım |

`php artisan migrate` **`pgsql_admin`** üzerinde çalışır (DDL transaction pooling'de sorun çıkarır ve rol yetkisi gerekir).

### 2.3 Kapsam modeli — tek değer değil, liste

**P-1'in çözümü.** Ofis kimliği tenant'a ait değildir ve kapsamındaki **birden çok** müşterinin verisini görür (spec T-3). Tek değerli bağlam bunu taşıyamaz.

Bağlam **tenant id listesidir**: `app.tenant_ids` oturum değişkeni, virgülle ayrılmış.

| Kimlik | Kapsam listesi |
|---|---|
| Müşteri kullanıcısı | Tek elemanlı: kendi tenant'ı |
| Ofis kullanıcısı | Organizasyon kapsamındaki müşteriler (Spec 3; geçici çözümde tümü — chunk6 §6.4) |
| Sistem yöneticisi | Erişilen tenant(lar); her erişim erişim logu üretir (spec T-4) |

**`BYPASSRLS` hiçbir role verilmez.** Sistem yöneticisi de listeyle çalışır — aksi halde o rol için ikinci savunma hattı tamamen kalkar.

> **RLS'in işi nettir:** *uygulamanın beyan ettiği kapsamı zorlamak.* Kapsamın doğru hesaplanması uygulama katmanının (Spec 3) sorumluluğudur. Kod bir `where` unutsa bile RLS beyan edilen kapsamın dışına çıkmayı engeller — iki katman böyle korunur.

### 2.4 Bağlam fonksiyonları ve policy deseni

Migration: `...000200_create_tenant_context_functions.php`

```sql
CREATE SCHEMA IF NOT EXISTS app;
GRANT USAGE ON SCHEMA app TO "3eegitim_app", "3eegitim_app_financial", "3eegitim_test";

-- Kapsam listesi. Boş string ve NULL'un ikisi de "bağlam yok" demektir (deney 6).
CREATE OR REPLACE FUNCTION app.tenant_ids() RETURNS bigint[]
LANGUAGE plpgsql STABLE AS $$
DECLARE v text := current_setting('app.tenant_ids', true);
BEGIN
  IF v IS NULL OR v = '' THEN
    RAISE EXCEPTION 'tenant context not set' USING ERRCODE = '42501';
  END IF;
  RETURN string_to_array(v, ',')::bigint[];
END $$;

-- Yazma için tek tenant zorunlu: kapsam listesi çok elemanlıysa hangi tenant'a
-- yazılacağı belirsizdir; belirsizken yazmaya izin verilmez.
CREATE OR REPLACE FUNCTION app.write_tenant_id() RETURNS bigint
LANGUAGE plpgsql STABLE AS $$
DECLARE ids bigint[] := app.tenant_ids();
BEGIN
  IF array_length(ids, 1) <> 1 THEN
    RAISE EXCEPTION 'write requires exactly one tenant in scope' USING ERRCODE = '42501';
  END IF;
  RETURN ids[1];
END $$;
```

```sql
-- FIRLATMAYAN varyant. Yalnız audit tablolarının INSERT politikasında kullanılır (chunk3 §3.6):
-- orada NULL tenant meşrudur ve fırlatan varyant OR ifadesini patlatır (deney 11).
CREATE OR REPLACE FUNCTION app.write_tenant_id_or_null() RETURNS bigint
LANGUAGE plpgsql STABLE AS $$
DECLARE v text := current_setting('app.tenant_ids', true); ids bigint[];
BEGIN
  IF v IS NULL OR v = '' THEN RETURN NULL; END IF;
  ids := string_to_array(v, ',')::bigint[];
  IF array_length(ids, 1) <> 1 THEN RETURN NULL; END IF;
  RETURN ids[1];
END $$;
```

`STABLE` zorunludur (`IMMUTABLE` yanlış — değer oturum içinde değişir).

⚠ `write_tenant_id_or_null()` **yalnız audit tablolarında** kullanılır. Normal tenant tablolarında fırlatan varyant kullanılır — sessiz NULL yerine anlaşılır hata isteriz.

**Her tenant-kapsamlı tablo için standart desen — KOMUT BAZLI dört politika:**

```sql
ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;
ALTER TABLE {table} FORCE ROW LEVEL SECURITY;

CREATE POLICY {table}_select ON {table} FOR SELECT
  USING (tenant_id = ANY (app.tenant_ids()));

CREATE POLICY {table}_insert ON {table} FOR INSERT
  WITH CHECK (tenant_id = app.write_tenant_id());

CREATE POLICY {table}_update ON {table} FOR UPDATE
  USING      (tenant_id = app.write_tenant_id())
  WITH CHECK (tenant_id = app.write_tenant_id());

CREATE POLICY {table}_delete ON {table} FOR DELETE
  USING (tenant_id = app.write_tenant_id());
```

**Neden tek politika değil dört politika — kanıtlanmış tuzak (deney 8):**

PostgreSQL'de **`WITH CHECK` DELETE'e uygulanmaz**; DELETE yalnız `USING`'e tabidir. Tek `tenant_isolation` politikası (`USING` liste + `WITH CHECK` tek tenant) yazılırsa okuma ve INSERT/UPDATE korunur ama **çok tenant'lı kapsamdaki bir ofis kullanıcısı kapsamındaki herhangi bir müşterinin satırını silebilir.** Canlı denemede doğrulandı: `WITH CHECK` politikasıyla `SET LOCAL app.tenant_ids='1,2,3'` altında `DELETE ... WHERE tenant_id=2` **başarılı oldu** (3 satır → 2).

Komut bazlı dört politikayla aynı deneme `ERROR: write needs single tenant` veriyor; okuma geniş (3 satır), tek tenant kapsamıyla silme çalışıyor, çapraz silme hiçbir satır silmiyor.

**Okuma geniş, yazma dar.** Ofis kullanıcısı bir müşteri adına yazacak veya silecekse kapsamını o tek tenant'a daraltır (`TenantContext::forWrite()` — §2.5).

Desen elle tekrarlanmaz: `app/Support/Database/TenantPolicy.php` — `TenantPolicy::apply('documents')` yukarıdaki **altı ifadenin tamamını** (2 ALTER + 4 CREATE POLICY) üretir. Yardımcı yalnız SQL üretir, karar vermez.

### 2.5 `TenantContext` — açık iş birimi

**P-3'ün çözümü.** `SET LOCAL` yalnız transaction içinde etkilidir; kancanın kendi kendine transaction açması "bu transaction'ı kim kapatacak?" sorusunu cevapsız bırakır ve PgBouncer'da bağlantı rehin kalır. Bu yüzden **transaction sınırı açıkça yazılır.**

`app/Support/Tenancy/TenantContext.php`:

| Metot | Davranış |
|---|---|
| `run(Closure $fn)` | **Ortam kapsamıyla** çalışır (§2.5.1 middleware'in belirlediği kapsam). Transaction açar → `SET LOCAL app.tenant_ids = '...'` → `$fn` → commit. |
| `run(array $tenantIds, Closure $fn)` | Kapsamı açıkça verir. |
| `forWrite(int $tenantId, Closure $fn)` | Kapsamı **tek tenant'a daraltır** — yazma ve silme için zorunlu (§2.4). |
| `current(): ?array` | Aktif kapsam veya `null`. |

**İç içe çağrı semantiği (kesin):**

1. Dışta aktif bir `TenantContext` varsa iç çağrı **yeni transaction AÇMAZ** — aynı transaction'da kalır.
2. İç çağrının kapsamı dıştakinin **alt kümesi** olmak zorundadır; değilse `TenantScopeWideningException`. (Genişletme yasak — daraltma serbest.)
3. İç çağrı kapsamı daraltıyorsa **`SET LOCAL` yeniden çalıştırılır** (aynı transaction içinde tekrar set edilebilir).
4. `$fn` bittiğinde **dıştaki kapsam geri yüklenir** — yine `SET LOCAL` ile. Geri yükleme `finally` bloğundadır; `$fn` exception fırlatsa da çalışır.

Bu semantik ofisin ana desenini mümkün kılar: `run([1..10], fn() => ... forWrite(5, fn() => ...) ...)` — geniş oku, tek müşteriye daralt, yaz, geri dön.

**Nerede çağrılır:**
- Her `App\Actions\*` sınıfı gövdesini `TenantContext::run()` veya `forWrite()` içine alır. **Action = iş birimi = transaction sınırı.**
- Her `App\Queries\*` sınıfı okuma sorgusunu `TenantContext::run()` içine alır.
- Bunun dışında tenant-kapsamlı sorgu çalıştırılmaz.

**Kanca yalnız bekçidir** (`app/Providers/TenancyServiceProvider.php`): tenant-kapsamlı bir tabloya, aktif `TenantContext` olmadan sorgu gitmeye çalışırsa `TenantContextMissingException` fırlatır. **Kanca transaction açmaz, bağlam set etmez** — sadece durdurur. Böylece reentrancy sorunu da doğmaz (kanca hiç sorgu çalıştırmaz).

⚠ **Audit yazımı için dar muafiyet (N2).** Ofis alanı olayları (ofis girişi dahil) `tenant_id = NULL` ile audit tablolarına yazılır ve o anda aktif `TenantContext` **yoktur**. Bekçi bunu engellememelidir. Muafiyet dar tutulur:

| Kural |
|---|
| Muafiyet yalnız `audit_writes`, `audit_access`, `audit_usage` tablolarına **INSERT** için geçerlidir. |
| Muafiyet yalnız `AuditWriter`, `AccessLogger` ve `UsageLogger` üzerinden yapılan yazımlarda tanınır (çağrı bağlamı işaretlenir) — genel bir tablo muafiyeti **değildir**. `LogUsage` middleware'i yazımı doğrudan yapmaz, `UsageLogger`'ı çağırır. |
| Bu tablolara **SELECT** her zaman `TenantContext` gerektirir. **Tek istisna:** `AuditReadScope` içinden `audit_writes` ve `audit_access` okumaları (chunk3 §3.6.1 B-6) — backoffice çapraz-tenant denetim okuması bağlam yerine `app.audit_read_all` bayrağına dayanır. Muafiyet yine **bileşene** tanınır (yalnız `AuditReadScope`), tabloya değil; `audit_usage` kapsam dışıdır. |
| Bağlamsız INSERT'te `tenant_id` NULL olmak **zorundadır**; `AuditWriter` tenant'lı satırı bağlamsız yazmayı exception ile reddeder (chunk3 N-1). |

⚠ Kanca **`pgsql` VE `pgsql_financial` bağlantılarının ikisine birden** kaydedilir. Yalnız `pgsql`'e kaydedilirse mali bağlantıdaki sorgular bekçisiz kalır (RLS yine durdurur ama uygulama katmanı eksilir — iki katman ilkesi bozulur).

#### 2.5.1 İstek yaşam döngüsü — bağlam nasıl kurulur

`app/Http/Middleware/EnsureTenantContext.php`. **Transaction AÇMAZ**; yalnız kapsamı hesaplar ve isteğe bağlar.

| Sıra | Adım |
|---|---|
| 1 | Kimlik çözülür (guard). Kimlik yoksa ve rota kimlik gerektiriyorsa auth middleware zaten reddetmiştir. |
| 2 | Kimlik alanına göre kapsam üretilir: **müşteri** → `[oturumdaki tenant_id]`; **ofis** → `ScopeResolver::accessibleTenantIds()` (chunk6 §6.5). |
| 3 | Kapsam istek ömrü boyunca `TenantScope` nesnesinde tutulur (container binding). |
| 4 | Action ve Query sınıfları `TenantContext::run(...)` çağırdığında bu kapsam kullanılır. |

⚠ **`TenantContext::run(Closure)` — kapsamsız çağrının semantiği** (heyet 134 F-11 düzeltmesi; metne heyet 135 F-9'da işlendi). Bir bileşen kendi sınırını açmak isteyip **kapsam belirtmezse**:

| Durum | Davranış |
|---|---|
| Aktif bir `TenantContext` **varsa** | O kapsam **korunur** ve closure onun içinde çalışır. Genişletme yapılmaz, exception atılmaz. |
| Aktif bağlam **yoksa** | İstek ömrüne bağlı ortam kapsamı (`TenantScope`) kullanılır. |

Gerekçe: kendi sınırını açan bir bileşen (`OfficeAuditFeed` gibi) daraltılmış bir Action'ın içinden çağrıldığında, ortam kapsamına dönmek **kapsamı sessizce genişletirdi** — daraltmanın amacı tam olarak bunu engellemekti. Genişletme yasağı (`TenantScopeWideningException`) açık kapsam verilen çağrılar için aynen geçerlidir; buradaki kural yalnız **kapsam verilmeyen** çağrıyı tarif eder ve kompozisyonu güvenli kılar.

**Middleware transaction açmaz** — açsaydı istek boyu bağlantı rehin kalırdı (P-3'ün reddettiği desen). Transaction sınırı Action/Query'dedir.

**Muaf rotalar** (`EnsureTenantContext` uygulanmaz): sağlık kontrolü, statik varlıklar, kimlik doğrulama uçları, webhook'lar (chunk7 §7.4). Muaf rota listesi `config('tenancy.context_free_routes')` — kesin liste, açık uçlu değil.

**Oturumdan tenant:** müşteri oturumu açıldığında `tenant_id` oturuma yazılır (chunk5 §5.5 adım 4). Sonraki her istekte kapsam **oturumdan** okunur; `customer_users` kaydı bağlam kurulduktan sonra normal kural altında yüklenir. Oturumdaki `tenant_id` ile kullanıcının gerçek `tenant_id`'si uyuşmuyorsa oturum sonlandırılır ve olay audit'lenir.

Muaf tablolar `config('tenancy.exempt_tables')` — §2.8'deki **kesin liste**.

**Yasak:** `SET` (LOCAL'siz) hiçbir yerde kullanılmaz. Arch testi **`SET app.*` ailesinin tamamını** `SET LOCAL` olmadan arar ve bulursa kırar — tek bir değişken adına bağlı değildir. Bugün `app.tenant_ids` ve `app.audit_read_all` (chunk3 §3.6.1 B-1) var; sonradan eklenen her `app.` oturum değişkeni **kendiliğinden** kapsama girer. Desen `set_config('app.…', …, false)` biçimini de arar (chunk9 S-4).

### 2.6 Agregat okuma

**Spec T-6:** toplu sayılar (kontenjan doluluğu gibi) tenant kapsamından **bağımsız** okunur; kapsamlı sorgudan türetilirse yanlış sonuç verir.

Çözüm: kapsam dışı okuma **satır içeriği döndürmeyen, yalnız agregat döndüren** `SECURITY DEFINER` fonksiyonlarla yapılır.

```sql
CREATE OR REPLACE FUNCTION app.count_all(p_table regclass, p_where jsonb)
```

— bu genel biçim **kullanılmaz** (keyfi sorgu kapısı olur). Bunun yerine **her agregat için ayrı, dar fonksiyon** yazılır; omurgada bir örnek bulunmaz, ilk örnekler dikey planlarda gelir (kontenjan sayacı).

Bu chunk'ta kurulan: sözleşme ve şablon.

| # | Kural |
|---|---|
| G-1 | Kapsam dışı okuma yalnız `SECURITY DEFINER` fonksiyonla yapılır; fonksiyon `app` şemasındadır ve `EXECUTE` yetkisi uygulama rollerine açıkça verilir. |
| G-2 | Fonksiyon **kapsam dışı satır içeriği döndürmez.** Dönüş değeri yalnız agregat (sayı, toplam, boolean) veya **küme üzerine** hesaplanmış kriptografik digest olabilir. ⚠ **Digest, çağıranın seçebildiği tek kayda veya tek alana uygulanamaz** — `sha256(bir_değer)` teknik olarak geri döndürülemezdir ama TCKN gibi küçük uzaylı bir değerde **tahmin-doğrulama oracle'ı** olur: saldırgan değeri tahmin eder, digest'i karşılaştırır, doğrular. İki koşul birden aranır: (a) digest **kümenin** özetidir, tek alanın değil; (b) çağıran **kümenin sınırlarını serbestçe seçemez** — sınırlar sistemin kendi ürettiği bir kayıttan gelir. Küme tek satıra düşebiliyorsa (mühür partisi gibi) bu **kabul edilmiş artık risktir** ve gerekçesi AD-06'da yazılıdır; sessizce geçilemez. ⚠ (b) koşulunu **fonksiyon imzası zorlayamaz** ve zorlaması da beklenmez — bir aralık parametresi almak zorundadır. Fiilî zorlama iki kapıdadır: yeni bir çağıran delil tablosunun adını taşımak zorunda olduğu için **chunk9 S-12 allowlist taramasına** takılır, ve yeni kapsam dışı fonksiyon/çağıran **G-5 gereği kod incelemesi maddesidir**. Bu bilinçlidir; imzaya bakıp "sınır kontrolü fonksiyonda olmalıydı" sonucu çıkarılmamalıdır (heyet 136 F-r3-1). Tek çağrıda birden çok agregat dönmesi gerekiyorsa **tek satırlık, sabit sütun listeli** `RETURNS TABLE` kullanılır; bu bir satır kümesi değil, bileşik skalerdir. Satır **içeriği** döndüren `SECURITY DEFINER` fonksiyonlar bu sınıfa ait değildir — onların yeri §2.7'nin A sözleşmesidir. |
| G-3 | Fonksiyon parametreleri tiplenmiştir; **dinamik SQL üretmez.** Tablo veya kolon adının parametreden gelmesi gerekiyorsa fonksiyon o adları gövdesine yazar ve dallanır. Allowlist + `%I` quoting bu kuralın istisnası **değildir**: kural saldırı yüzeyini azaltmayı değil, sıfırlamayı ister — ve allowlist zaten tabloları saymışsa dinamik biçim hiçbir şey kazandırmaz. |
| G-4 | Her fonksiyonun ne döndürdüğü ve neden kapsam dışı olduğu yorumla yazılır (`COMMENT ON FUNCTION`). |
| G-5 | Yeni bir kapsam dışı fonksiyon eklemek **kod incelemesi maddesidir** ve chunk9 §9.5 taramasında listelenir. |

> **G-2 ve G-3'ün gerekçesi (Sets kararı, 26.07.2026 — heyet 134 F-5).** İlk yazımda G-2 dönüş **tiplerini** sayıyordu ("yalnız sayı, toplam veya boolean") ama korumak istediği **kuralı** söylemiyordu; kontenjan sayacı düşünülerek yazılmış, digest sınıfı bir dönüş öngörülmemişti. Yukarıdaki metin kuralı açıkça yazar ve bunu yaparken **daraltır**: "satır içeriği dönmez" yasağı ilk kez metne girmiştir. G-3 ise doğru yazılmıştı; chunk3'teki ihlal kuralı değil kodu bağlar ve kod düzeltilmiştir.

#### 2.6.1 Kapsam dışı YAZAN fonksiyonlar (W sözleşmesi)

G kuralları baştan sona **okuma** hakkındadır. Kapsam dışı **yazan** bir `SECURITY DEFINER` (türev/agregat tablo dolduran bir toplulaştırma işi gibi) G'nin hiç kapsamadığı ayrı bir sınıftır ve G'ye dayandığı iddiasıyla yazılamaz — §2.7'nin A sözleşmesi gibi kendi kurallarını taşır.

Bu sınıf **istisnadır**: RLS altında çok tenant'lı tek `INSERT` mümkün değildir (yazma politikası tek bir `app.write_tenant_id()` ister), tenant başına döngü ise tenant listesi gerektirir ve §2.8.1'in tavuk-yumurta sorununa geri düşer. Bir fonksiyon bu sınıfa ancak ikisi de doğruysa girer.

| # | Kural |
|---|---|
| W-1 | Kapsam dışı yazma yalnız `SECURITY DEFINER` fonksiyonla yapılır; fonksiyon `app` şemasındadır, `EXECUTE` yetkisi uygulama rollerine açıkça verilir. |
| W-2 | Fonksiyon **yalnız kendi türev tablosuna** yazar — kaynağı okur, agregatı yazar. İş verisine (tenant-kapsamlı işlem tabloları) yazan bir `SECURITY DEFINER` **hiçbir gerekçeyle** kabul edilmez. |
| W-3 | Kaynak ve hedef tablolar gövdeye yazılır, parametreden gelmez (G-3'ün yazma karşılığı). |
| W-4 | Parametreler yalnız **aralık** belirtir (tarih, id) ve tiplenmiştir; kullanıcı girdisi yazma yoluna girmez. |
| W-5 | Yazdığı satırlar ve dönüş değeri **kişi kırılımı taşımaz** (AD-21); dönüş yalnız yazılan satır sayısıdır. |
| W-6 | G-4 ve G-5 aynen geçerlidir; fonksiyon chunk9 §9.5 S-9 envanterine **W sınıfı olarak ayrı** girer, G ile aynı torbaya konmaz. |

Bugünkü tek örneği chunk3 §3.9'un `app.roll_up_usage_daily` fonksiyonudur.

### 2.7 Kimlik doğrulama bootstrap kapısı

**P-2'nin çözümü.** Spec §4.1 B-1…B-6: kimlik doğrulama, bağlam belirlenmeden yürütülebilen tek işlemdir; istisna **tabloya değil işleme** aittir.

```sql
CREATE OR REPLACE FUNCTION app.authenticate_lookup(p_email citext)
RETURNS TABLE (id bigint, tenant_id bigint, password_hash text, status text)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public, app
AS $$
  SELECT id, tenant_id, password_hash, status
  FROM customer_users
  WHERE email = p_email
$$;
```

| # | Kural |
|---|---|
| A-1 | Fonksiyon **yalnız** dört alan döndürür: id, tenant_id, kimlik doğrulama sırrı, hesap durumu. Ad, telefon, TCKN, eğitim geçmişi bu yoldan **okunamaz**. |
| A-2 | `EXECUTE` yetkisi yalnız uygulama rollerine verilir. |
| A-3 | Tenant-kapsamlı tablolara genel "bağlamsız okuma" muafiyeti **tanımlanmaz** — `customer_users` RLS'li kalır; istisna bu tek fonksiyondur. |
| A-4 | Doğrulama başarılı olur olmaz `TenantContext::run([$tenantId])` açılır; sonraki her erişim normal kurala tabidir. |
| A-5 | Başarılı ve başarısız her deneme audit'lenir (chunk3). |
| A-6 | Magic link bu kapıyı **kullanmaz**: token kaydı zaten tenant'ı taşır (chunk5 §5.4). |

Ofis girişi bu kapıya ihtiyaç duymaz — `users` tenant tablosu değildir (§2.8).

### 2.8 Bağlam gerektirmeyen tabloların KESİN listesi

Liste "örneğin" ile açık uçlu bırakılmaz. Bir tablo ya bu listededir ya tenant-kapsamlıdır.

| Tablo | Neden bağlamsız |
|---|---|
| `migrations`, `jobs`, `job_batches`, `failed_jobs`, `cache`, `cache_locks`, `sessions` | Altyapı; tenant verisi taşımaz. ⚠ `sessions` **chunk5'te düşürülür** (§5.1.1: üç alanın oturum tablosu ayrıdır); listede o güne kadar kalır çünkü bugün kullanımdadır |
| `backoffice_users`, `backoffice_user_sessions`, `backoffice_auth_settings`, `backoffice_password_reset_tokens` | **Backoffice kimliği** — hiçbir tenant'a ait değil (spec §3, v1.11). Backoffice'in müşteri verisine erişebiliyor olması (§3.3) bu tabloları tenant-kapsamlı yapmaz |
| `users`, `user_sessions`, `user_auth_settings`, `password_reset_tokens` | **Ofis kimliği** — hiçbir tenant'a ait değil (spec §3). `password_reset_tokens` implementasyonda eklendi: Laravel iskeletinin ofis tarafı tablosu, §2.8 listesinde unutulmuştu (26.07.2026) |
| `customers` | Tenant'ın **kendisi**. **Muaf değildir** — RLS'lidir, politika `id` üzerinden kurulur (aşağıda). Ofis kapsam listesi bu tablodan **doğrudan okunmaz**; §2.8.1'e bakınız |
| `permissions`, `roles`, `model_has_roles`, `model_has_permissions` | **Ofis** izin ailesi; tenant verisi değil |
| `backoffice_permissions`, `backoffice_roles`, `backoffice_user_permissions`, `backoffice_user_roles` | **Backoffice** izin ailesi (chunk6 §6.1, spec §3 v1.11) — ofisinkiyle paylaşılmaz, ikisi de bağlamsızdır. ⚠ Müşteri izin ailesi (`customer_*`) bu listede **değildir**: o aile tenant-kapsamlıdır ve RLS uygular |
| `regulations`, `regulation_versions` | Mevzuat kaydı — tüm tenant'lar için ortak (AD-27) |
| `settings`, `notification_templates`, `document_types` | Admin-tanımlı tanımlar; ortak |
| `office_user_customers` | **Ofis kapsam atamaları** (chunk6 §6.5.2) — hangi ofis kullanıcısının hangi müşteriye eriştiğini söyler. Satırın öznesi tenant'a ait **olmayan** bir kimlik olduğu için tenant-kapsamlı yapmak daire olurdu |
| `customer_auth_settings` | **Müşteri alanının auth ayarları** — ayarın ekseni kimlik ALANIdır, tenant değil (spec §5.1): müşteri firma, portalın hangi giriş yöntemlerini sunduğunu seçmez, 3E seçer (chunk5 §5.1) |
| `customer_user_sessions` | **Müşteri oturumları** — satır her istekte, kimlik doğrulanmadan ÖNCE okunur; kapsam ise oturumdan hesaplanır. RLS burada dairesel olurdu ve uygulamanın en sıcak yoluna üçüncü bir `SECURITY DEFINER` kapısı dayatırdı. Satıra yalnız oturum id'siyle ulaşılır ve o id zaten taşıyıcı sırrın kendisidir (chunk5 §5.1.1) |
| `consent_texts` | Rıza **metinleri** 3E'nin kendi metnidir, backoffice'te tanımlanır ve her müşteri için aynıdır (chunk4 §4.5). Rıza **kayıtları** (`consents`) tenant-kapsamlıdır ve bu listede **yoktur** |
| `reference_codes` | Kod benzersizliği global olmalı; kayda erişim yine policy'den geçer (chunk6 R-5) |
| `magic_links` | **Muaf değildir** — RLS'lidir. Token doğrulaması §2.8.2'deki dar fonksiyonla yapılır |
| `audit_stamps` | Damga kayıtları; tenant kırılımı yok |

**Bu listede olmayan her tablo tenant-kapsamlıdır** ve §2.4 desenini uygular. Yeni tablo eklerken ikisinden biri seçilmek zorundadır; seçilmemişse arch testi kırar (§2.9).

`customers` özel durumu: RLS'lidir ama ayırt edici kolon `tenant_id` değil `id`'dir.

```sql
CREATE POLICY customers_select ON customers FOR SELECT USING (id = ANY (app.tenant_ids()));
CREATE POLICY customers_insert ON customers FOR INSERT WITH CHECK (true);   -- gerekçe aşağıda
CREATE POLICY customers_update ON customers FOR UPDATE
  USING (id = app.write_tenant_id()) WITH CHECK (id = app.write_tenant_id());
-- DELETE politikası YOK: tenant silinmez, pasifleştirilir (AD-23). Politikasızlık
-- default-deny üretir ve silme fiilen imkânsızlaşır — bu istenen davranıştır.
```

⚠ **`INSERT WITH CHECK (true)` bilinçli bir istisnadır.** Yeni müşterinin `id`'si INSERT anında üretilir ve hiçbir mevcut kapsam listesinde olamaz; `id = app.write_tenant_id()` yazılsaydı **yeni firma kaydı DB katmanında kalıcı olarak reddedilirdi** ve ofis onboarding akışı çalışmazdı.

Bu istisna izolasyonu zayıflatmaz: **yeni satır oluşturmak mevcut hiçbir tenant'ın verisini açığa çıkarmaz.** Buradaki risk yetkisiz *oluşturma*dır, veri sızıntısı değil — ve yetki uygulama katmanının işidir (`customer.create` izni, chunk6). RLS'in koruduğu şey (bir tenant'ın verisinin başkasına görünmesi) INSERT'te söz konusu değildir.

#### 2.8.1 Ofis kapsam listesinin kaynağı — tavuk-yumurta çözümü

Ofis kimliğinin kapsam listesi **`customers` tablosundan doğrudan okunmaz** (bağlam kurulmadan okunamaz, kurmak için de liste gerekir).

| Durum | Kaynak |
|---|---|
| **Nihai (Spec 3)** | Kapsam, **ofis tarafındaki organizasyon/atama tablolarından** hesaplanır. Bu tablolar `users` gibi bağlamsızdır (chunk2 §2.8) → tavuk-yumurta yok. |
| **Geçici (bu plan)** | ~~`SimpleScopeResolver` "tüm tenant'lar" döndürür. Liste `app.all_customer_ids()`'ten gelir.~~ → **değiştirildi, aşağıdaki nota bakınız.** |

> **Düzeltme (Sets onayı, 26.07.2026 — heyet F-5).** `app.all_customer_ids()` `customers` tablosunu gerektiriyor ve o tablo kapsam düzeltmesiyle chunk5'e taşındı; dolayısıyla "tüm tenant'lar" geçici çözümü chunk2'de kurulamaz.
>
> **Ara dönem davranışı (chunk2 → chunk5/6):** `SessionScopeResolver` yalnız **müşteri** tarafını çözer (kapsam oturumdaki `tenant_id`'den gelir). **Ofis kimliği boş kapsam alır** ve tenant verisine dokunan ilk sorguda isimli bir exception ile düşer.
>
> Gerekçe: geniş bir geçici kapsam ("tüm tenant'lar") üretime kadar **çalışıyor görünür**; boş kapsam ilk denemede durur. Chunk6 §6.5'in prod çıkış kısıtı zaten `TreeScopeResolver` olmadan gerçek müşteri verisiyle prod'a çıkılamayacağını söylüyor — ara dönemde ofis tarafının hiç çalışmaması bu kısıtla tutarlıdır.
>
> **Chunk5/6 implementer'ına:** `SimpleScopeResolver` diye bir sınıf **yoktur**. `app/Domain/Authorization/ScopeResolver` sözleşmesi ve `App\Support\Tenancy\SessionScopeResolver` bağlaması vardır; `TreeScopeResolver` bu bağlamanın yerine geçer. `app.all_customer_ids()` gerekiyorsa chunk5'te tabloyla birlikte kurulur ve S-9 envanterine "geçici" olarak girer.

⚠ `app.all_customer_ids()` **geçicidir**: `TreeScopeResolver` (Spec 3) devreye girdiğinde **kaldırılır**. chunk9 §9.5 S-9 envanterinde "geçici" olarak işaretlidir; envanterden düşürülmediği sürece tarama uyarı üretir.

#### 2.8.2 Magic link token doğrulaması

`magic_links` tenant-kapsamlıdır; token'ı bulmak için bağlam gerekir ama tenant token'dan öğrenilecektir. §2.7'nin deseni burada da uygulanır — istisna **tabloya değil işleme** ait:

```sql
CREATE OR REPLACE FUNCTION app.magic_link_lookup(p_token_hash text)
RETURNS TABLE (id bigint, tenant_id bigint, customer_user_id bigint,
               expires_at timestamptz, used_at timestamptz, purpose text)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public, app
AS $$ SELECT id, tenant_id, customer_user_id, expires_at, used_at, purpose
      FROM magic_links WHERE token_hash = p_token_hash $$;
```

- Yalnız altı alan döndürür; kullanıcı adı, e-posta veya başka veri **bu yoldan okunamaz**.
- Bulunduktan sonra `TenantContext::run([$tenantId])` açılır; `used_at` güncellemesi ve kullanıcı yüklemesi **normal kural altında** yapılır.
- S-9 envanterine girer (chunk9 §9.5).

### 2.9 Model katmanı ve zorlama

`app/Models/Concerns/BelongsToTenant.php`:

- `creating` olayında `tenant_id` set edilmemişse aktif kapsamdan (tek elemanlıysa) doldurur.
- **Global scope EKLEMEZ** — filtreleme RLS'in işidir. Global scope eklenirse RLS'in çalışıp çalışmadığı test edilemez hale gelir.
- `tenant_id` **`$guarded`**; kütlesel atamayla değiştirilemez.
- `updating` olayında `tenant_id` değişmişse exception — bir kayıt tenant değiştiremez.

**Arch testleri:**

| Kural | Test |
|---|---|
| Her model ya `BelongsToTenant` kullanır ya `config('tenancy.exempt_tables')` listesindedir | `TenancyTest.php` |
| `BelongsToTenant` kullanan her modelin tablosunda RLS politikası var | Migration sonrası şema sorgusuyla |
| `SET` (LOCAL'siz) kullanımı yok | `TenancyTest.php` |

**§3.2 (kimlik tenant sınırını aşmaz):**
- `customer_users` tablosunda e-posta, TCKN hash ve telefon üzerinde **global unique index YOKTUR**. Unique yalnız `(tenant_id, alan)` bileşiminde.
- Çapraz tenant arama/eşleştirme/öneri üreten sorgu yazılmaz — chunk9 §9.5 taraması denetler.

### 2.10 Mali tablolar için ikinci katman

Spec §6 Y-1/Y-2 ve AD-03'ün ilkesi: izolasyon tek katmana emanet edilmez. Satır kapsamı RLS ile çözülür; **"bu rol bu tabloyu hiç görmemeli"** ise RLS'in işi değildir (RLS satır eler, tabloyu gizlemez).

**Karar:** hassasiyet sınıfı başına DB rolü. Bu chunk'ta **iki sınıf**:

| Rol | Mali tablolara erişim |
|---|---|
| `3eegitim_app` | **Yok** (`REVOKE ALL ON {financial_tables}`) |
| `3eegitim_app_financial` | Var |

- Mali tablo listesi `config('tenancy.financial_tables')`; omurgada henüz mali tablo yoktur, liste boş başlar ve dikey planlarda dolar. **Mekanizma şimdi kurulur** ki mali tablolar geldiğinde eklenecek yer belli olsun.
- Rol seçimi istek başına yapılır: kullanıcı `financial.view` iznine sahipse `3eegitim_app_financial` bağlantısı, değilse `3eegitim_app`. Seçim `TenantContext` içinde, bağlantı adı üzerinden.
- Dikey başına değil **hassasiyet sınıfı başına** rol: bir kişi iki dikeyde birden yetkili olabilir, rol kombinasyonu patlaması olmasın.

⚠ `SET LOCAL ROLE` **kullanılmaz** — ayrı bağlantı tanımı kullanılır. Sebep: PgBouncer havuzu bağlantıyı kullanıcı adına göre ayırır; rol değiştirme havuz semantiğiyle karışır.

### 2.11 Test stratejisi

**İki suite.** `RefreshDatabase` her testi transaction'a sarar; bu sarmalama içinde `SET LOCAL` prod davranışını yansıtmaz.

| Suite | Grup | Yöntem | Paralel |
|---|---|---|---|
| Normal | `omurga` | `RefreshDatabase` | ✅ |
| RLS | `omurga-rls` | Truncation, transaction sarmalaması yok, `3eegitim_test` rolü | ❌ |

`tests/RlsTestCase.php::setUp()` **ilk iş**:
```
assertFalse(DB::selectOne("SELECT rolsuper FROM pg_roles WHERE rolname = current_user")->rolsuper)
```

**Zorunlu testler:**

| # | Test | Kanıtladığı |
|---|---|---|
| R-1 | Tenant A kapsamıyla tenant B kaydı sorgulanır → 0 satır | T-1 |
| R-2 | Uygulama katmanı atlanıp doğrudan sorgu → yine 0 satır | AD-03 iki katman |
| R-3 | Bağlamsız sorgu → exception | §4.1 |
| R-4 | Kapsam A iken tenant B adına INSERT → RLS ihlali | `WITH CHECK` |
| R-5 | **Çok tenant'lı kapsamla INSERT → exception** | `write_tenant_id()` |
| R-5b | **Çok tenant'lı kapsamla DELETE → exception** | Deney 8/9 — `FOR DELETE` politikası |
| R-5c | **Çok tenant'lı kapsamla UPDATE → exception** | `FOR UPDATE USING` |
| R-6 | **Çok tenant'lı kapsamla SELECT → her iki tenant'ın satırları** | Ofis okuma (P-1) |
| R-7 | `COMMIT` sonrası bağlam boş | Deney 6 |
| R-8 | Ardışık isteklerde bağlam sızmıyor (PgBouncer) | Deney 7 |
| R-9 | **Test rolü superuser değil** | Deney 1 tuzağı |
| R-10 | `TenantContext` dışında tenant-kapsamlı sorgu → exception | §2.5 bekçi |
| R-11 | İç içe `TenantContext`: alt küme kabul, üst küme `TenantScopeWideningException` | §2.5 |
| R-11b | İç içe daraltma sonrası **dıştaki kapsam geri yükleniyor**; iç `$fn` exception fırlatsa da geri yükleniyor | §2.5 |
| R-16 | `magic_link_lookup` yalnız altı alan döndürüyor | §2.8.2 |
| R-17 | Muaf rota listesi dışındaki bir rota `EnsureTenantContext` olmadan çalışmıyor | §2.5.1 |
| R-12 | Aynı TCKN ile iki tenant'ta kişi yaratılabiliyor, kayıtlar birbirini görmüyor | §3.2 |
| R-13 | `tenant_id` güncelleme girişimi exception | §2.9 |
| R-14 | `authenticate_lookup` yalnız dört alan döndürüyor; başka veri okunamıyor | §2.7 A-1 |
| R-15 | Mali tablo listesi doluyken `3eegitim_app` rolüyle o tabloya `SELECT` → **permission denied** | §2.10 |

**R-9 atlanamaz.** Bu test olmadan diğerleri yanlış güven verir.

### 2.12 Kabul kriterleri

1. Dört DB rolü migration ile kurulu; uygulama `3eegitim_app` ile bağlanıyor; `SELECT rolsuper` → `false`.
2. Hiçbir role `BYPASSRLS` verilmemiş (şema sorgusuyla kanıt).
3. `pgsql` PgBouncer portunu, `pgsql_admin` doğrudan portu kullanıyor; migration `pgsql_admin` üzerinde.
4. `userlist.txt.example` ve README uygulama rollerini ve superuser uyarısını içeriyor.
5. `app.tenant_ids()` ve `app.write_tenant_id()` kurulu; NULL ve boş string'in ikisini de yakalıyor.
6. Her tenant-kapsamlı tabloda `ENABLE` + `FORCE RLS` ve **komut bazlı dört politika** (SELECT liste, INSERT/UPDATE/DELETE tek tenant). Tek birleşik politika kullanılmamış (şema sorgusuyla kanıt).

   **Standart desen istisnaları — bunlar kriter 6'ya dahil değildir, her biri kendi kriteriyle sınanır:**

   | Tablo | Sapma | Nerede |
   |---|---|---|
   | `customers` | INSERT `WITH CHECK (true)`; DELETE politikası yok; ayırt edici kolon `id` | §2.8, kriter 7 |
   | `audit_writes`, `audit_access` | INSERT `write_tenant_id_or_null()` varyantı; UPDATE/DELETE `USING (true)` | chunk3 §3.6, chunk3 kriter 2 |
   | `audit_usage` | UPDATE/DELETE politikası yok (retention chunk-drop ile çalışır) | chunk3 §3.6 |

   Şema doğrulama testi bu istisna listesini bilir; listede olmayan bir tablo standart desenden saparsa test kırılır. **İstisna eklemek plan değişikliği gerektirir** — kod incelemesiyle geçilemez.
7. `customers` tablosunda `id` bazlı politika var.
8. `TenantContext::run()` transaction açıyor, bağlamı set ediyor, commit ediyor; iç içe çağrıda yeni transaction açmıyor.
9. Bekçi kanca transaction açmıyor ve bağlam set etmiyor — yalnız exception fırlatıyor; **`pgsql` ve `pgsql_financial` bağlantılarının ikisine de kayıtlı**.
9b. `EnsureTenantContext` middleware'i kapsamı hesaplıyor ve transaction **açmıyor**; muaf rota listesi kesin.
9c. `app.all_customer_ids()` geçici olarak işaretli ve S-9 envanterinde; `app.magic_link_lookup()` yalnız altı alan döndürüyor.
10. `config('tenancy.exempt_tables')` kesin liste; listede olmayan ve `BelongsToTenant` kullanmayan model arch testini kırıyor.
11. `authenticate_lookup` yalnız dört alan döndürüyor; `customer_users` RLS'li kalmış.
12. Mali rol ayrımı kurulu; liste boş olsa da mekanizma test edilebilir (test tablosuyla kanıt).
13. `BelongsToTenant` global scope eklemiyor; `tenant_id` guarded ve değiştirilemez.
14. `customer_users` tablosunda global unique index yok; unique yalnız `(tenant_id, alan)`.
15. R-1…R-15 yeşil; `omurga-rls` paralel değil.

---

## Chunk 3 — Audit: iş olayı, erişim, kullanım


**Bağımlılık:** chunk1, chunk2
**Spec dayanağı:** §7 (7.1–7.5), §8.3, §8.5
**Mimari dayanak:** AD-06, AD-16, AD-21
**Bitiş şartı:** §3.10 kabul kriterleri.

> **Plan revizyonu r1 (Sets, 26.07.2026 — AD-09 ve AD-21 güncellemesi).** Bu chunk onaylandıktan sonra ofis/backoffice ayrımı ve log görünürlüğü netleştirildi. Dört değişiklik: (a) `audit_activity` → **`audit_usage`** ("aktivite" artık iş olaylarını anlatıyor); (b) `audit_writes` okumasına **aktör ayrımı** eklendi (§3.6, N-4); (c) backoffice'in **çapraz-tenant okuma yolu** tanımlandı (§3.6.1); (d) iş akışı ekranlarının audit'ten beslenmemesi kuralı yazıldı (§3.1).
>
> **Plan revizyonu r2 (heyet review 130 — `docs/reviews/review-egt50-ofis-backoffice-2026-07-26.md`).** r1'in dört bulgusu düzeltildi: **F-4** §3.6.1'in teknik yolu kurulamıyordu (tenant listesi numaralandırma) → **bağlam beyanı** desenine geçildi (`app.audit_read_all`, B-1…B-4); **F-6** N-4'ün tek-katman gerekçesi olgusal olarak yanlıştı → gerekçe düzeltildi ve chunk9 **S-12** taraması eklendi; **F-10** `actor_type` enum'u spec §3.0 ile çelişiyordu → `participant` kaldırıldı; **F-7** chunk9'a devredilen taramalar chunk9'un kesin listesinde yoktu → **S-11…S-14** eklendi. Ayrıca açık soru 1 kapatıldı (`system` satırları ofis feed'inde görünür). Kabul kriterleri 15–20.
>
> **F-3 karara bağlandı (Sets, 26.07.2026):** kimlik doğrulama olayları ofis feed'inde **kalır**; kırmızı çizgi 17 üretim tarafında (S-2) zorlanır. Gerekçe §3.6'da.

---

### 3.1 Üç log sınıfı — üç ayrı tablo

| Sınıf | Tablo | Yazım garantisi | Damga | Saklama |
|---|---|---|---|---|
| **İş olayı audit'i** | `audit_writes` | İşlemle **aynı transaction**. Yazılamazsa işlem gerçekleşmez. | Evet | Silinmez |
| **Erişim logu** | `audit_access` | Aynı sınıf. Yazılamazsa **hassas veri görüntülenmez**. | Evet | Silinmez |
| **Kullanım logu** | `audit_usage` | Gevşek. Yazılamazsa sayfa **engellenmez**. | Hayır | Admin ayarı, taban 730 gün |

Üçü ayrı tablodur: hacim profilleri, garanti seviyeleri ve saklama politikaları farklı. Tek tabloda birleşirse kullanım hacmi damgalama ve sorgu maliyetini yönetilemez yapar.

Ek tablo: `audit_stamps` — damgalanmış batch kayıtları (bağlam gerektirmez, chunk2 §2.8).

**Aksiyon log değildir (AD-21).** Müşterinin teklifi onaylaması, revize talep etmesi, mesaj göndermesi; katılımcının katılımı, notu, check-in'i — bunlar **iş verisidir**. Bu chunk'ın kurduğu üç tablo bunların üzerine yazılan **denetim katmanıdır**, işin kendi kaydı değil.

⚠ **İş akışı ekranları audit tablosundan beslenmez.** Teklifin ekrandaki zaman çizelgesi teklifin kendi verisinden gelir, `audit_writes`'tan değil. Aksi halde denetim katmanının izolasyon, maskeleme ve saklama kuralları arayüze sızar; audit hem asıl işini yapamaz hem de UI bağımlılığı yüzünden değiştirilemez hale gelir. Zorlaması **chunk9 S-13** taramasıdır.

### 3.2 Append-only zorlaması — trigger, RULE değil

**P-6'nın çözümü.** `DO INSTEAD NOTHING` ihlali sessizce yutar: bir bug audit satırını güncellemeye çalışırsa işlem "başarılı" görünür ve fark edilmez. Gözlemlenebilirlik kaybı kabul edilmez.

```sql
CREATE OR REPLACE FUNCTION app.deny_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'audit records are append-only: % on %', TG_OP, TG_TABLE_NAME
    USING ERRCODE = '42501';
END $$;

CREATE TRIGGER audit_writes_append_only
  BEFORE UPDATE OR DELETE ON audit_writes
  FOR EACH ROW EXECUTE FUNCTION app.deny_mutation();
```

Aynı trigger `audit_access` için de kurulur. `audit_usage`'ye **kurulmaz** (saklama süresi dolunca silinebilmeli).

⛔ **Düzeltme, Sets 07.08.2026:** `audit_usage` üzerinde retention policy YOK — hiçbir log otomatik silinmiyor (saklama yükümlülüğü 10 yıla kadar uzuyor). Append-only trigger'ın yokluğu artık retention için değil, **`PurgeUsageLog`** için: kişinin kendi kararıyla, izniyle ve audit satırıyla sildiği bir backoffice eylemi var ve her DELETE'i reddeden bir trigger onu da reddederdi.

### 3.3 Transaction garantisi

Spec §7.1: *"audit yazılamıyorsa işlem de gerçekleşmez."*

- Audit kaydı iş kaydıyla **aynı transaction** içinde yazılır. Kuyruğa atılmaz, `afterCommit` kullanılmaz.
- `app/Support/Audit/AuditWriter.php` yazımı üstlenir; hata **yutulmaz**, yukarı fırlar, transaction geri alınır.
- Damgalama ve WORM arşivleme **async** (§3.8) — bunlar dış servistir ve spec §12'nin "dış servis akışı durdurmaz" ilkesine tabidir.

Ayrım testle sabitlenir: audit yazımı engellendiğinde iş kaydı **oluşmamalı**; damgalama servisi engellendiğinde iş kaydı ve audit **oluşmalı**, damga kuyrukta beklemeli.

### 3.4 İş olayı audit'i

**Paket:** `owen-it/laravel-auditing` v14.0.6, yalnız yazma audit'i için. Erişim ve kullanım logları paketin kapsamı dışındadır; kendi yazılır.

Paketin kendi `audits` tablosu yerine `audit_writes` ve `App\Models\AuditWrite` kullanılır (`config/audit.php` → `implementation`) — append-only trigger, maskeleme ve damgalama alanlarını kendimiz ekliyoruz.

**Kapsam (spec §7.1 asgari listesi):** teklif oluşturma/gönderme/onay/red, onay adımları ve atlanan onaycılar, masraf giriş/onay/red, kullanıcı oluşturma/pasifleştirme/yetki değişikliği, evrak yükleme/onay/red, eğitim durum geçişleri, bakanlık bildirim kaydı, sertifika kodu girişi, iptal ve revizyonlar, tanım/parametre/mevzuat değişiklikleri, kimlik doğrulama olayları.

Bu chunk'ta gerçekten üretilen: **kimlik doğrulama olayları** ve **kullanıcı/yetki yönetimi** (chunk5, chunk6). Diğerleri dikey planlarda bağlanır — ancak **altyapı ve zorlama şimdi kurulur**.

**Zorlama (arch testi `AuditTest.php`):** `BelongsToTenant` kullanan her model `OwenIt\Auditing\Contracts\Auditable` implement eder. Etmiyorsa test kırılır. Bu, spec'in *"audit opt-in bir trait olamaz"* kuralının makine karşılığıdır.

### 3.5 Erişim ve kullanım logları

**Erişim logu (spec §7.2):** evrak görüntüleme/indirme, TCKN/SGK/IBAN görüntüleme, özel nitelikli veri görüntüleme, teklif metni okuma, sertifika kodu görüntüleme.

- `app/Support/Audit/AccessLogger.php` + `LogsAccess` trait'i.
- Hassas alan okuma noktaları **açıkça işaretlenir**; otomatik yakalama denenmez (her attribute erişimini yakalamak yanlış pozitif üretir ve performansı bozar).
- Hangi alanların hassas olduğu `config('audit.sensitive_fields')`.
- **Yazım garantisi sert:** log yazılamıyorsa görüntüleme yapılmaz (spec §7.2). `AccessLogger` hatayı yutmaz.
- ⚠ **Geniş kapsamlı okumada log yazımı (N4).** Ofis kullanıcısı çok-tenant'lı kapsamla (`run([1..10])`) bir müşterinin hassas alanını görüntülediğinde, erişim logu satırı o kaydın tenant'ıyla yazılır. Ama INSERT politikası tek tenant ister → çok-tenant kapsamla yazım reddedilir → sert garanti gereği **görüntüleme engellenir**. Bu, ofisin geniş okuma modunda hiçbir hassas alanı görüntüleyememesi demektir.
  **Çözüm:** `AccessLogger` log satırını **ilgili kaydın tenant'ına daraltılmış iç bağlamda** yazar: `TenantContext::forWrite($recordTenantId, fn () => ...)` (chunk2 §2.5 iç içe semantiği — daraltma serbest, çıkışta geri yüklenir). Ofis alanı olaylarında (kayıt bir tenant'a ait değilse) `tenant_id` NULL yazılır ve §3.6 muafiyeti geçerlidir.
- Kayıt: kim, ne zaman, hangi kayıt, hangi alan sınıfı, IP, oturum.

**Kullanım logu (spec §7.3):** `app/Http/Middleware/LogUsage.php` — yalnız `GET` isteklerinde ve yalnız liste/detay rotalarında. Kayıt: kullanıcı, rota, uygulanan filtre, dönen kayıt sayısı.

Middleware yazımı **doğrudan yapmaz**; `app/Support/Audit/UsageLogger.php`'yi çağırır. Bekçi muafiyeti (chunk2 §2.5) bu bileşene tanınır.

⚠ Kullanım logu **hiçbir kullanıcıya satır satır gösterilmez** (AD-21) — ne ofiste, ne müşteri panelinde. Backoffice'te yalnız agregat karşılığı görünür (§3.9, chunk8). Bu tablonun bir listeleme ekranı yazılırsa kişi bazlı davranış izlemesi doğar ve kırmızı çizgi 17 ihlal edilir.

**Yazım bağlamı (M1) — kesin:**

| İstek | Bağlam |
|---|---|
| Müşteri isteği | `UsageLogger`, satırı **ortam kapsamına daraltılmış** iç bağlamda yazar: `TenantContext::forWrite($tenantId, ...)`. Middleware Action/Query transaction'ının dışındadır, bu yüzden kendi kısa iş birimini açar. |
| Ofis isteği | `tenant_id` **NULL** yazılır; §3.6 NULL disiplini ve chunk2 §2.5 muafiyeti geçerlidir. |

⚠ Bu tanım olmadan kullanım kaydı ya bekçiye takılır ya da gevşek garanti gereği **her istekte sessizce düşer** — log işlevsiz kalır ve fark edilmez.

- Bir listede 200 satır göründüğünde 200 erişim kaydı değil, **tek** kullanım kaydı üretilir.
- **Yazım garantisi gevşek:** hata loglanır, istek devam eder.

### 3.6 Audit tablolarının tenant durumu

**P-9'un ikinci yarısı.**

| Tablo | Tenant | Politika |
|---|---|---|
| `audit_writes` | Tenant-kapsamlı, **`tenant_id` nullable** | Aşağıdaki NULL disiplini |
| `audit_access` | Tenant-kapsamlı, **`tenant_id` nullable** | Aynı disiplin |
| `audit_usage` | Tenant-kapsamlı, **nullable** — ofis kimliğinin kullanımında NULL | `USING (tenant_id IS NULL OR tenant_id = ANY(app.tenant_ids_or_empty()))` — **bayrak yok** (§3.6.1 B-3); `WITH CHECK` `or_null` varyantı |
| `audit_stamps` | Bağlamsız (chunk2 §2.8) | RLS yok |

**NULL disiplini — üç audit tablosu için ortak.** Ofis alanı olayları (ofis girişi, `users` yönetimi, `UserPermissionsChanged`) hiçbir tenant'a ait değildir; bu satırlarda `tenant_id` NULL'dur.

```sql
-- Fırlatmayan okuma varyantı ZORUNLU: §3.6.1'in bayrağı bu ifadeye OR ile
-- ekleniyor ve PostgreSQL OR'u kısa devre etmez (deney 11).
CREATE POLICY audit_writes_select ON audit_writes FOR SELECT
  USING (app.audit_read_all()
         OR tenant_id IS NULL
         OR tenant_id = ANY (app.tenant_ids_or_empty()));

-- Fırlatmayan varyant ZORUNLU (chunk2 deney 11): fırlatan write_tenant_id()
-- bağlamsız oturumda OR ifadesini patlatır ve NULL satır da yazılamaz.
CREATE POLICY audit_writes_insert ON audit_writes FOR INSERT
  WITH CHECK (tenant_id IS NULL
              OR tenant_id IS NOT DISTINCT FROM app.write_tenant_id_or_null());

-- UPDATE/DELETE politikaları GEREKLİ — trigger'ın ateşlenmesi için (deney 10).
CREATE POLICY audit_writes_update ON audit_writes FOR UPDATE USING (true);
CREATE POLICY audit_writes_delete ON audit_writes FOR DELETE USING (true);
```

⚠ **`USING (true)` politikaları neden var (N1).** RLS etkin bir tabloda bir komut için **hiç politika yoksa PostgreSQL default-deny uygular: komut hata vermez, 0 satırla eşleşir.** Bu durumda `BEFORE UPDATE` trigger'ı **hiç ateşlenmez** ve ihlal denemesi yine sessizleşir — §3.2'nin kovduğu davranışın aynısı. Canlı denemede doğrulandı (deney 10): politikasız UPDATE hata vermedi. `USING (true)` ile satır policy'den geçer, trigger `RAISE EXCEPTION` eder, ihlal **görünür** kırılır (deney 10 düzeltmesi doğrulandı).

`USING (true)` izolasyonu zayıflatmaz: trigger her UPDATE/DELETE'i koşulsuz reddeder, satıra ulaşılmaz.

| # | Kural |
|---|---|
| N-1 | `tenant_id` NULL **yalnız ofis alanı olaylarında** üretilir. Müşteri alanı olayında NULL yazmak kod hatasıdır; `AuditWriter` bunu exception ile reddeder. |
| N-2 | NULL satırların okunması **policy ile** kısıtlanır (chunk6): müşteri kimliği `audit.view` iznine sahip olsa bile NULL satırları göremez. RLS burada tek başına yetmez; bu bilinçli bir istisnadır ve testle sabitlenir. |
| N-3 | `audit_usage` için de aynı disiplin geçerlidir. |
| N-4 | **Aktör ayrımı (AD-21, 26.07.2026).** Ofis tarafında `audit_writes` okuması yalnız **ofis kimliklerinin** ve **sistemin** ürettiği satırları döndürür. Müşteri kimliklerinin denetim izi ofiste hiçbir ekranda görünmez. |

⚠ Bu istisna olmadan **ofis girişi audit'lenemez**: bağlam yoktur, NULL yasaksa satır yazılamaz. İstisna dar tutulmuştur (yalnız NULL, yalnız ofis alanı) ve okuma tarafı policy ile kapatılmıştır.

**N-4'ün uygulanışı.** Üç audit tablosuna `actor_type` kolonu eklenir — **üç değer:** `office_user` · `customer_user` · `system`.

⚠ `participant` **ayrı bir aktör türü değildir.** Spec §3.0'a göre katılımcılık ayrı bir kimlik değil, aynı `customer_users` kaydının **yeteneğidir**; aktör her durumda o kayıttır. Katılımcı ekranından üretilen olay da `customer_user` yazar. Kimliği yeteneğe göre tiplemek spec §3.0 ile çelişir ve iki yeteneği olan kişide hangi türün yazılacağı tanımsız kalırdı.

**Ofis feed'i `office_user` ve `system` satırlarını döndürür.** `system` dışarıda bırakılırsa "bu kaydı kim değiştirdi" sorusunun cevabı zamanlanmış geçişlerde **boş** kalır ve yönetici kaydın kendiliğinden değiştiğini sanır. `system` satırı bir kişiyi izlemez, dolayısıyla N-4'ün amacına aykırı değildir.

Zorlama **RLS'te değil sorgu katmanındadır.** Gerekçe *"RLS bilemez"* değildir — RLS tenant'ı da yalnızca uygulama `SET LOCAL` ile beyan ettiği için bilir (chunk2 §2.4). Gerçek gerekçe şudur: aktör türü bir **satır niteliğidir**, oturum niteliği değil; aynı rol hem ofis hem backoffice sorgusunu koşar, dolayısıyla RLS'te ayrım ancak **yeni bir oturum beyanı** (ikinci bir GUC) ile yapılabilirdi. Bu, izolasyon için değil **görünürlük** için ikinci bir bypass anahtarı açmak olurdu; §3.6.1'de zaten bir tane açılıyor ve iki anahtarın yan yana durması hangisinin neyi kapattığını bulanıklaştırır.

Tek katmanın telafisi **iki mekanizmadır**:

- `app/Queries/Audit/OfficeAuditFeed.php` içinde `whereIn('actor_type', ['office_user', 'system'])`; sınıfın imzası **aktör türünü parametre kabul etmez** (chunk8 §8.6 k-anonimlik deseninin aynısı). Kriter 16 bunu reflection ile sabitler.
- **Kural taraması (chunk9 S-12):** `audit_writes` / `audit_access` üzerinde `App\Queries\Audit\**` dışından sorgu yazılamaz. Sınıf imzası atlanamaz olsa da **sınıfın kendisi atlanabilir**; taramasız haliyle "sorgu sınıfı atlanamaz" iddiası karşılıksızdır.

⚠ Bu, müşterinin **iş aksiyonlarını** kapsamaz: onay, revize talebi ve yazışma iş verisidir, teklifin kendi ekranından gelir (§3.1).

**Kimlik doğrulama olayları feed'de kalır (Sets kararı, 26.07.2026 — heyet F-3).** Ofis personelinin giriş/çıkış olayları `audit_writes` kapsamındadır ve ofis feed'inde görünür (spec §7.5 L-1b). Heyet bu satırlardan çevrimiçi sürenin çıkarılabileceğini söylemişti; gerekçe kabul edilmedi: giriş olayı kullanım süresini göstermez — sabah giriş yapıp gün boyu panele bakmayan kişi de aynı satırı üretir. Ölçtüğü şey oturum açma anıdır.

Kırmızı çizgi 17'nin koruması yerinde durur ve **üretim tarafında** zorlanır: sistem bu olaylardan süre, oturum uzunluğu, aktiflik skoru veya sıralama **üretmez** — chunk9 **S-2** taraması zaten bunu denetliyor. `OfficeAuditFeed` olay listesi döndürür, türetilmiş metrik döndürmez; bu sınırı sınayan **kriter 15b**'dir (kriter 16 yalnız parametre imzasını kapsar).

#### 3.6.1 Backoffice çapraz-tenant okuma

Backoffice tüm tenant'ların denetim kaydını görür. Bu yeni bir yetki değil, **spec T-4'ün denetim kayıtlarına uygulanışıdır** (spec §7.5 L-4); izolasyon kaldırılmaz, kapsam tek bir yoldan genişletilir.

- Tek giriş noktası: `app/Queries/Audit/BackofficeAuditFeed.php`. Başka hiçbir yerde çapraz-tenant audit okuması yapılmaz; **chunk9 S-11** taraması bunu denetler.
- Yol **açıkça yetkilendirilir**: `audit.view_all_tenants` izni (chunk6'da **backoffice izin ailesinde**), backoffice hesabı olmak tek başına yetmez. *(Eski metin "`system.access_backoffice` tek başına yetmez" diyordu; o izin spec v1.11 ile kaldırıldı — backoffice'e giriş artık bir ofis izni değil, `backoffice_users` kaydının kendisidir.)*
- Okumanın **kendisi audit'lenir**: erişim logu sınıfında, "hangi yönetici, hangi filtreyle, kaç kayıt" (AD-21 erişim logu deseni).
- Denetim **dışı** iş verisi bu yoldan okunmaz; yol yalnız üç audit tablosunu kapsar.

**Teknik yol — tenant listesi numaralandırılmaz.** İlk taslak "bağlamı tüm tenant kimlikleriyle açmak" diyordu; bu kurulamaz (heyet F-4): `customers` tablosu RLS'lidir ve tüm id'leri okumak için tüm id'lerin zaten bağlamda olmasını gerektirir (chunk2 §2.8'in tavuk-yumurtası), tek numaralandırma fonksiyonu `app.all_customer_ids()` geçicidir ve `SECURITY DEFINER`'dır — bu maddenin kendi yasağı.

Yerine **bağlam beyanı** kullanılır; chunk2'nin `app.tenant_ids` deseninin aynısı:

```sql
-- Non-throwing okuma varyantı: OR ifadesinde fırlatan fonksiyon kullanılamaz
-- (chunk2 deney 11 — PostgreSQL her permissive politikayı değerlendirir).
CREATE FUNCTION app.tenant_ids_or_empty() RETURNS bigint[] ...   -- bağlamsızsa '{}'
CREATE FUNCTION app.audit_read_all()      RETURNS boolean ...    -- bağlamsızsa false

-- Yalnız audit_writes ve audit_access; audit_usage bayrağı taşımaz (B-3).
CREATE POLICY audit_writes_select ON audit_writes FOR SELECT
  USING (app.audit_read_all()
         OR tenant_id IS NULL
         OR tenant_id = ANY (app.tenant_ids_or_empty()));
```

| # | Kural |
|---|---|
| B-1 | `app.audit_read_all` **yalnız `SET LOCAL` ile**, transaction içinde kurulur (AD-04). Oturum düzeyinde set edilmesi chunk9 **S-4** taramasına takılır; S-4'ün deseni bu chunk ile birlikte `SET app.*` genelliğine çekilir (chunk2 §2.5 ve chunk9 §9.5 güncellendi). |
| B-2 | Bayrağı kuran **tek yer** `BackofficeAuditFeed`'dir ve izin kontrolünden **sonra** kurar (S-11). |
| B-3 | Bayrak yalnız **`audit_writes` ve `audit_access`** SELECT politikalarında geçer. `audit_usage` **kapsam dışıdır**: kullanım logu hiç kimseye satır satır gösterilmez (L-3), backoffice onu yalnız `analytics_usage_daily` agregatından okur (§3.9, chunk8 §8.6) — çapraz-tenant ham usage okumasının bir kullanım senaryosu yoktur. Yüzey ne kadar darsa o kadar iyidir. Başka tabloda geçmesi izolasyonun genel bypass'ı olur; S-11 bunu da tarar. |
| B-4 | `BYPASSRLS`'li rol veya `SECURITY DEFINER` fonksiyon **kullanılmaz**; chunk2'nin superuser dersi birebir geçerlidir. Bayrak RLS'i kapatmaz, politikanın bildiği bir koşuldur. |
| B-5 | **Bayrak kurulduğu iş biriminde kapatılır.** `SET LOCAL` transaction sonuna kadar yaşar; bu, feed'i çağıran Action'ın **aynı transaction'daki sonraki her audit sorgusunun** bayrağı miras alması demektir — izin kontrolünden geçmemiş bir sorgu çapraz-tenant dönerdi. Bu yüzden bayrak `app/Support/Audit/AuditReadScope.php` iş birimiyle kurulur: **önceki değer okunur** → `SET LOCAL ... = 'on'` → sorgu → **`finally` içinde önceki değer geri yüklenir**. chunk2 §2.5'in iç içe bağlam semantiğinin birebir aynısıdır: sabit `'off'` yazmak iç içe kullanımda dış scope'u sessizce bayraksızlaştırır ve feed **hata vermeden eksik sonuç** döndürür — bu projenin en sevmediği hata sınıfı. `finally` olmasının sebebi ayrıca exception yoludur: fırlasa da bayrak açık kalmaz. |
| B-5a | **`app.audit_read_all()` sözleşmesi:** yalnız değer tam olarak `'on'` iken `true` döner. `NULL`, `''` (boş string — chunk2 deney 6: COMMIT sonrası bırakılan değer) ve `'off'` üçü de `false`'tur. Bu üç durumun ayrı ayrı sınanması kriter 17b'nin parçasıdır. |
| B-6 | **`BackofficeAuditFeed` tenant bağlamı açmaz.** Bekçi (chunk2 §2.5) audit tablolarına SELECT için aktif bağlam ister; bu yol bağlam yerine bayrağa dayandığı için muafiyet **bileşene** tanınır — chunk2'nin `AuditWriter`/`AccessLogger` deseninin aynısı: yalnız `AuditReadScope` içinden, yalnız iki audit tablosuna, yalnız SELECT. Tabloya genel muafiyet **değildir**; `AuditReadScope` dışından gelen bağlamsız audit SELECT'i bekçiye takılmaya devam eder (kriter 17e). |
| B-7 | **`OfficeAuditFeed` bu yolun parçası değildir.** Ortam kapsamıyla normal `TenantContext::run()` içinde koşar, muafiyet gerektirmez, bayrağa dokunmaz. |
| B-8 | **Okumanın erişim logu** bir **ofis alanı olayıdır**: `tenant_id` NULL yazılır (§3.6 N-1) ve `AccessLogger`'ın mevcut muafiyetinden geçer. Yazım, okuma bittikten ve bayrak kapandıktan **sonra** yapılır — log satırının kendisi bayraklı bağlamda üretilmez. |

⚠ **Kabul edilen bedel:** audit tablolarının SELECT politikası artık fırlatmayan `app.tenant_ids_or_empty()` kullanıyor, yani bağlamsız bir audit SELECT'i hata yerine "yalnız NULL-tenant satırlar" döndürür. Bağlamsız okumayı kıran katman **uygulama bekçisidir** (B-6, kriter 17e).

⚠ **`CASE` alternatifi değerlendirildi ve reddedildi.** `CASE WHEN app.audit_read_all() THEN true ELSE tenant_id = ANY(app.tenant_ids()) END` değerlendirme sırasını garanti eder ve fırlatan varyantı koruyabilirdi. Reddedilme sebebi: hata **satır bağımlı** olurdu — bağlamsız oturum NULL-tenant satırları tarar, ilk NULL-olmayan satırda patlar; yani "temiz hata" deterministik olmaz, aynı sorgu veriye göre bazen döner bazen fırlatır. Deney 11'in dersi (OR kısa devre etmez) bu yüzden `CASE` ile aşılmaz, yalnız yer değiştirir. Bu not, ileride "CASE ile fırlatan varyanta dönelim" denemesini önlemek için buradadır.

### 3.7 Maskeleme

Spec §8.3, AD-16: hassas değerler audit'e **yazım anında** maskelenir.

- `app/Support/Masking/Masker.php`: TCKN `482•••••316`, telefon `(5••) ••• •• 47`, IBAN son 4 hane, e-posta `a•••@domain.com`.
- Maskeleme `AuditWriter` içinde, `old_values`/`new_values` JSON'una yazılmadan **önce**. Sonradan maskeleme yoktur — bir kez ham yazılırsa silinemez tabloda ham kalır.
- Maskelenecek alan listesi `config('audit.masked_fields')`.

### 3.8 TimescaleDB, damgalama, WORM

**Hypertable:** üç audit tablosu da.

- `audit_writes`, `audit_access`: 30 günlük chunk, **retention YOK**. ⚠ Sıkıştırma yok — TimescaleDB columnstore RLS'li tabloda kullanılamıyor (canlı doğrulandı, 2.28.3/PG17); RLS pazarlıksız olduğu için sıkıştırmadan vazgeçildi.
- `audit_usage`: 7 günlük chunk, **retention YOK** *(Sets, 07.08.2026 — düzeltme)*. Policy yazılmıştı ve **canlıydı**: 730 günden eskiyi her gece düşürüyordu. `2026_10_02_000100_no_log_disappears_on_a_schedule` kaldırdı, `config('audit.usage_retention_days')` anahtarı silindi, iki kontrol geri gelmesini engelliyor — biri ağacı tarıyor, biri **TimescaleDB'nin iş kataloğunu** okuyor. Sıkıştırma da yok (row security ile bir arada olmuyor).
- ⚠ TimescaleDB birincil anahtarın partition kolonunu içermesini ister → birincil anahtar `(id, created_at)` bileşiktir.

**Mühürleme (AD-06, güncellendi 26.07.2026):** log satırına değil, periyodik **batch**'e.

> **Sapma — KamuSM kaldırıldı (Sets kararı, 26.07.2026).** Plan RFC 3161 / KamuSM diyordu; ESHS'ye bağlanmıyoruz. Yerine **kendi zincirimiz**: batch başına Merkle kökü + batch'leri bağlayan hash zinciri + zincir hash'inin dış tanığa yayımlanması. Delil sınıfı değişti (takdiri delil), gerekçesi ve hukuk teyidi AD-06 ve `docs/issues.md`'de.

- `app/Jobs/SealAuditBatch.php` — saatlik schedule. Son mühürden bu yana eklenen kayıtların **Merkle kökünü** hesaplar (`app.audit_batch_merkle_root`, G-1 deseninde skaler dönen SECURITY DEFINER), önceki mührün zincir hash'iyle katlar, **dış tanığa yayımlar, sonra** `audit_stamps`'e yazar.

  ⚠ **Sıra pazarlıksız: hesapla → yayımla → kaydet (Sets, 26.07.2026).** Yayımlanmamış bir halka hâlâ bizim tarafımızda ve yeniden yazılabilir; yerel oluşturma zamanı ile tanığın kayıt zamanı arasındaki fark, bu yapıyı inceleyen herkesin soracağı ilk şeydir. Bu yüzden fark **yok**: ya ikisi birden oluşur ya hiçbiri. Yayım başarısız olursa yerel kayıt da oluşmaz.

  Sıra güvenli çünkü **mühür deterministiktir**: aynı satırlar + aynı önceki zincir hash'i → aynı kök ve aynı zincir hash'i. Yayım başarısızsa sonraki koşum birebir aynısını üretir; kayıt yayımdan sonra başarısızsa sonraki koşum yine aynısını üretir ve tanık onu **zaten yayımlanmış** olarak tanır. İki taraf ayrışmaz, birleşir. (Canlı ölçüm: mühür `created_at` 02:30:33, GitHub commit 02:30:31 — aynı saniye aralığı, toplam 4.3 sn.)

- `app/Jobs/PublishAuditRoots.php` — günlük **telafi** işi. Ana yol burada değil; halka onu üreten iş tarafından yayımlanır. Bu iş yalnız "tanık henüz yapılandırılmamışken yazılmış halkalar" durumunu kapatır. **Yalnız hash ve sayılar** dışarı çıkar; audit satırı, kişisel veri veya tenant adı çıkmaz.
- `app/Jobs/VerifyAuditChain.php` — günlük. Her partinin kökünü **satırlardan yeniden hesaplayıp mühürdekiyle karşılaştırır**, zinciri ileri katlar, **ve her halkayı tanığın elindekiyle karşılaştırır** (iki yönlü: tanıkta olup bizde olmayan, bizde olup tanıkta olmayan). Uyuşmazlığı **raporlar**, onarmaz — sessizce yeniden mühürleyen bir iş, var olma sebebi olan sinyali siler.

  ⚠ Kökü yeniden hesaplamak `app.audit_batch_merkle_root`'a **üst sınır** parametresi (`p_to_id`) gerektirir. Üst sınır olmadan kök yalnız "`from_id`'den sonraki her şey" üzerine alınabilir; sonraki satır düştüğü anda tarihsel bir partinin kökü bir daha hesaplanamaz — yani karşılaştırma yapısal olarak imkânsızdı. İlk yazımda fonksiyon çağrılıyor ama dönüşünün **yalnız satır sayısı** kullanılıyordu; kök karşılaştırması hiç yapılmıyordu (heyet 135 F-2). Sonuç: kriter 22'nin kendi senaryosu üretimde koşsa günlük doğrulama **sıfır bulgu** üretirdi.

  ⚠ Zinciri yalnız kendisiyle karşılaştırmak, tanığın var olma sebebi olan saldırıyı kaçırır: satırları değiştirip **yeniden mühürleyen** biri, yerelde kusursuz doğrulanan bir zincir bırakır. Yalnız yayımlanmış kopya itiraz eder.
- `app/Integrations/AuditWitness/` — adaptör arkasında. **API contract'ı resmî dokümandan doğrulanmadan gerçek çağrı yazılmaz** (CLAUDE.md kural 3). Bu chunk'ta arayüz + `NullAuditWitness` yazılır; gerçek tanık repository açılınca bağlanır ve `docs/issues.md`'de kalemdir.
- Mühürleme veya yayımlama başarısızsa iş yeniden denenir; audit yazımı etkilenmez.

⚠ **Tanık kopyası her okumada tazelenir.** `published()` yerel klonu `fetch` etmeden okursa, karşılaştırma **uygulamanın kendi yazabildiği** bir kopyaya karşı yapılır: satırları ve mühürleri yeniden yazabilen biri `storage/` altındaki dosyaları da düzenler ve karşılaştırma "tanıkla uyumlu" der. Bağımsızlık okuma anında kaybolurdu (heyet 135 F-3).

⚠ **`witnessed_at` yalnız tanığın kendi kayıt zamanıdır** ve tanık bunu bildiremiyorsa **NULL kalır**. Git tanığında commit tarihi bizimdir ve istenen değere ayarlanabilir; gerçek zaman forge'un push kaydındadır ve onu bir insan okur. Kendi saatimizi bu kolona yazmak, mahkemenin "tanığın kayıt zamanı" olarak okuyacağı tek alana **uygulama saati** koymak olurdu (heyet 135 F-5). Mührü ne zaman ürettiğimiz zaten `created_at`'tedir; ikisi hiçbir zaman aynı iddia değildi. Yayımlanmışlığın işareti `witness_reference`'tır — `PublishAuditRoots` telafi taramasını buna göre yapar.

**Merkle, düz özet değil.** Düz hash batch'in değişmediğini gösterir ama **tek bir satırı** kanıtlamak için batch'in tamamını göstermeyi gerektirir — ve batch birden çok müşterinin kaydını taşır. Merkle kökü, tek satırı yalnız kardeş hash'leriyle kanıtlatır. Ağaç saklanmaz: satırlar silinmediği için her kanıt istendiğinde yeniden hesaplanır.

**Mühür tablosu da dondurulur.** `audit_stamps` üzerinde `app.deny_seal_rewrite()` trigger'ı: DELETE tamamen reddedilir, UPDATE ise zincir alanlarından (`audit_table`, `epoch_date`, `from_id`, `to_id`, `row_count`, `merkle_root`, `previous_chain_hash`, `chain_hash`, `hash_algorithm`) herhangi birine dokunuyorsa reddedilir. Yalnız `witnessed_at`, `witness_reference`, `archived_at`, `archive_path` yazılabilir kalır — bunlar sonradan doldurulur ve zincir onlara dayanmaz.

Gerekçe: bu olmadan zincirin yumuşak karnı var — satırları yeniden yazan biri **yeniden mühürleyebilir** ve yerel zincir tertemiz doğrular.

⚠ **Tek yaprak kopyalanmaz.** Tek kalan düğüm yukarı olduğu gibi taşınır; son yaprağı kopyalamak klasik Merkle malleability açığıdır (iki farklı batch aynı kökü üretebilir).

**Yaprak satırın TAMAMINI kapsar.** Yaprak `app.audit_leaf_line(r audit_writes)` / `app.audit_leaf_line(r audit_access)` fonksiyonlarından çıkar ve satırın **her kolonunu** içerir — `event`, `user_id`, `actor_type`, `old_values`, `new_values`, `url`, `ip_address`, `user_agent`, `tags` dahil.

⚠ İlk yazımda yaprak yalnız `(id, created_at, tenant_id)` kapsıyordu ve bu, AD-06'nın "batch'teki tek bir satır değişse kök değişir" iddiasını **zarf için doğru, içerik için yanlış** kılıyordu: trigger'ı kapatabilen biri bir onay kaydının `new_values`/`user_id`'sini değiştirip geri açtığında kök, zincir, tanık ve doğrulama işinin **hepsi temiz** kalıyordu (heyet 135 F-1). Yalnız satırın var olduğunu koruyan bir zincir, kimsenin sahtesini yapmak istemeyeceği şeyi korur.

**Alanlar uzunluk önekiyle kodlanır** (`app.audit_leaf_part`). Audit satırları serbest metin taşır (url, user agent, tag) ve ayırıcı karakter metnin içinde geçebilir; `a|bc` ile `ab|c` aynı yaprağı üretir ve iki farklı satır aynı yaprağa düşebilirdi. Uzunluk değerin nerede bittiğini söyler, `NULL` kendi işaretini taşır — boş dizeyle karışamaz. `octet_length` kullanılır: bayt tartışmasızdır, karakter tanıma bağlıdır.

**Kolon listesi teste sabitlenir.** Delil tablosuna sonradan eklenen bir kolon, sessizce hash'in dışında kalırdı — v2'nin kapattığı kusurun aynısı, arka kapıdan. Şema uyum testi iki tablonun kolon kümesini yaprak fonksiyonundaki listeye karşı sabitler; kolon eklemek **testi kırar** ve yaprak kuralının sürümlenmesini zorunlu kılar.

**Kural adlandırılır ve mühürde taşınır.** Bugünkü kuralın adı **`sha256-utc-iso-v2`** ve mührün `hash_algorithm` alanına bu ad yazılır — hash fonksiyonunun adı değil. Kuralları kendinden uzun yaşayan bir zincirde, sonradan yapılan bir düzeltme daha önce mühürlenmiş her halkayı sessizce geçersizleştirir; halka hangi kural altında üretildiğini kendi üzerinde söylemek zorundadır. `VerifyAuditChain` bu alanı **okur** ve tanımadığı kuralla mühürlenmiş halkayı yanlış kuralla karşılaştırmak yerine raporlar.

⚠ **Zaman damgası oturumdan bağımsız yazılır** (26.07.2026, canlı doğrulandı). `timestamptz` doğrudan metne çevrilirse oturumun `TimeZone` ve `DateStyle` ayarına göre yazılır; aynı satır üç ayarda üç farklı yaprak üretti (`2026-07-26 02:55:20.188807+00` · `2026-07-26 05:55:20+03` · `26.07.2026 05:55:20 +03`). Dışarıdan doğrulayan bilirkişi kendi ayarıyla farklı kök hesaplar ve **zinciri bozuk görürdü** — bağımsız doğrulanabilirlik iddiasının tam merkezinde bir kusur. Kural `to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US')` ile sabitlendi; aynı üç ayarda aynı çıktı verdiği doğrulandı. `app.audit_leaf_line()` ayrı bir fonksiyon olarak duruyor ki kural **tek bir adreste** olsun ve tanık deposunun README'sinde birebir aynı biçimde yazılabilsin.

**WORM (AD-06):** `app/Jobs/ArchiveAuditBatch.php`, günlük — mühür kayıtları S3-uyumlu depoya Object Lock ile yazılır. **Yalnız mühür arşivlenir, audit satırları değil:** satırlar kişisel veri taşır ve delil zincirin kendisidir; satırları silinemez bir kovaya kopyalamak, zaten bir evi olan veriye ikinci ve kalıcı bir ev açardı.

### 3.9 Kullanım analitiği toplama

Spec §8.5. Bu chunk **veri toplama**yı kurar; ekranlar chunk8'de.

- Kaynak: `audit_usage`.
- ⚠ **Continuous aggregate kullanılamıyor** — RLS'li hypertable üzerinde kurulamıyor (canlı doğrulandı). Yerine sıradan tablo + `app.roll_up_usage_daily()` + saatlik job. Bu fonksiyon kapsam dışı **yazan** bir SECURITY DEFINER'dır; G kuralları okuma hakkında olduğu için chunk2 **§2.6.1 W sözleşmesine** tabidir (W-1…W-6) ve S-9 envanterine W sınıfı olarak girer. Önce G-1'e dayandırılmıştı — heyet 134 F-5'te yanlış beyan olduğu görüldü ve sınıf ayrıca tanımlandı. Planın istediği değişmez korunuyor: `user_id` aggregate'e taşınmıyor, distinct sayım **tam** (k-anonimlik eşiği yaklaşık sayıyla çalışamaz).
- `analytics_usage_daily` — boyutlar `(day, tenant_id, role, route)`, ölçüler `event_count`, `person_count` (distinct sayı). *(Bu satır önce "TimescaleDB continuous aggregate" diyordu; yukarıdaki ⚠ maddesi devreye girince güncellenmesi unutulmuştu — heyet 135 F-7.)*
- **`user_id` aggregate'te BULUNMAZ.** Ham tabloda vardır (retention'a tabi), aggregate'e taşınmaz — kişi kırılımı teknik olarak üretilemez.
- `person_count` k-anonimlik eşiği için tutulur; kimlikler değil sayı.

### 3.10 Kabul kriterleri

1. Dört tablo (`audit_writes`, `audit_access`, `audit_usage`, `audit_stamps`) mevcut.
2. `audit_writes` ve `audit_access` üzerinde UPDATE/DELETE **exception** üretiyor (sessiz yutma değil) — SQL denemesiyle kanıt. `USING (true)` politikaları mevcut; politikasız bırakılırsa deneme "0 rows" üretir ve kriter karşılanmaz.
2b. Bağlamsız NULL-tenant audit INSERT'i çalışıyor; çok-tenant kapsamla tenant'lı INSERT ve çapraz tenant INSERT reddediliyor (test — chunk2 deney 12).
2c. Ofis kullanıcısı çok-tenant kapsamla hassas alan görüntülediğinde erişim logu yazılıyor ve görüntüleme **engellenmiyor** (test — N4).
3. `audit_usage` üzerinde retention policy **çalışmıyor ve çalışmayacak** *(Sets, 07.08.2026)*; append-only trigger yokluğu `PurgeUsageLog`'un elle silmesi içindir.
4. Audit yazımı iş işlemiyle aynı transaction'da; audit engellendiğinde iş kaydı da oluşmuyor (test).
5. Damgalama engellendiğinde iş kaydı ve audit oluşuyor, damga kuyrukta bekliyor (test).
6. `BelongsToTenant` kullanan her model `Auditable` implement ediyor (arch testi, kasıtlı ihlalle doğrulanmış).
7. Hassas alan güncellemesinden sonra `audit_writes` içinde ham değer **yok** (string arama testi).
8. Erişim logu yazılamıyorsa hassas veri görüntülenmiyor (test).
9. Kullanım logu yazılamıyorsa sayfa görüntüleme engellenmiyor (test).
9b. Kullanım kaydı **gerçekten yazılıyor** — hem müşteri isteğinde (tenant'lı) hem ofis isteğinde (NULL) — ve bekçiye takılmıyor (test). *Gevşek garanti, "hiç yazılmıyor" anlamına gelmez.*
10. Bir listede N satır görüntülemesi **tek** kullanım kaydı üretiyor (test).
11. Üç audit tablosunda da `tenant_id` nullable; NULL yalnız ofis alanı olaylarında üretiliyor (müşteri alanında NULL yazma girişimi exception — test).
11b. ~~Müşteri kimliği `audit.view` izniyle bile NULL tenant'lı audit satırlarını **göremiyor** (test).~~ → **chunk6 §6.9 madde 7d'ye devredildi** (Sets kararı, 26.07.2026 — heyet 134 F-13). Kriter `customer_users` (chunk5) ve `audit.view` izni (chunk6) olmadan sınanamıyordu; index'in "sonraki chunk'a bağımlı kriter yoktur" kuralı gereği düşürüldü. Emsal: index P-4. ⚠ Taşıma değil **tekilleştirme**: aynı kriter chunk6 §6.9'da 7d olarak zaten yazılıydı, yani 11b baştan beri çift kayıttı. **Kuralın kendisi bu chunk'ta zaten kuruludur** — `audit_*_select` politikaları `tenant_id IS NULL` satırını yalnız kapsam listesi üzerinden değil, bayrak veya NULL eşleşmesiyle açar; taşınan yalnız izin katmanının **kanıtıdır**.
11c. Ofis girişi başarıyla audit'leniyor (NULL tenant ile) — test.
12. Üç audit tablosu da hypertable; retention yalnız `audit_usage`'de.
13. `analytics_usage_daily` aggregate'inde `user_id` kolonu **yok** (şema sorgusuyla kanıt).
14. Dış tanık adaptörü arayüz + null implementasyon; mühürler tanıksız işaretleniyor. Gerçek tanık (repository + adaptör) `docs/issues.md`'de açık kalem — repo şartları (public, force-push kapalı, dar yetkili kimlik) orada yazılı.
15. `actor_type` kolonu üç audit tablosunda mevcut, dolduruluyor ve **tam olarak üç değer** alıyor (`office_user`, `customer_user`, `system` — `participant` yok, şema/enum sorgusuyla kanıt). Ofis feed'i `office_user` + `system` döndürüyor; müşteri kullanıcısının ürettiği satır feed'de **görünmüyor** (test — N-4).
15b. Ofis personelinin **giriş/çıkış olayları ofis feed'inde görünüyor** (test — L-1b); `OfficeAuditFeed` dönüşünde süre, oturum uzunluğu, sayım veya skor gibi **türetilmiş alan yok** (test) — kırmızı çizgi 17 üretim tarafında duruyor.
16. `OfficeAuditFeed` imzası aktör türünü **parametre kabul etmiyor** (reflection testi) — filtre çağrı yerinden atlanamaz.
17. Backoffice çapraz-tenant okuma **tek sınıftan** geçiyor; `audit.view_all_tenants` izni olmadan reddediliyor ve okumanın kendisi erişim logu üretiyor (test — §3.6.1).
17b. `app.audit_read_all` bayrağı **yalnız `SET LOCAL` ile** kuruluyor ve COMMIT sonrası düşüyor; bayraksız oturum başka tenant'ın satırını **göremiyor**, bayraklı oturum görüyor (test — B-1/B-2). `app.audit_read_all()` sözleşmesi ayrıca sınanıyor: `NULL`, `''` ve `'off'` üçü de `false` (test — B-5a).
17b-2. **Bayrak iş birimi dışına sızmıyor (B-5).** `AuditReadScope` bittikten **sonra aynı transaction içinde** koşan ikinci bir audit SELECT'i çapraz-tenant satır **döndürmüyor**; scope içinde exception atıldığında da bayrak kapanıyor (`finally` testi). *Bu kriter olmadan yeni desenin tek gerçek güvenlik boşluğu açık kalır.*
17b-3. **İç içe scope dış scope'u bozmuyor (B-5).** İç `AuditReadScope` kapandıktan sonra dış scope'un sorguları **hâlâ çapraz-tenant dönüyor** — sabit `'off'` yazan bir implementasyon bu testte kırılır (sessiz eksik sonuç probu).
17c. Bayrak **yalnız `audit_writes` ve `audit_access`** SELECT politikalarında geçiyor: bayrak açıkken tenant-kapsamlı bir iş tablosuna **ve `audit_usage`'a** sorgu hâlâ reddediliyor (test — B-3). Kasıtlı ihlal probu: bayrağı fixture tabloya taşıyan politika testi kırıyor.
17d. `BYPASSRLS`'li rol ve `SECURITY DEFINER` fonksiyon yok — chunk9 S-9 envanteriyle doğrulanıyor (B-4).
17e. **Bekçi muafiyeti bileşene tanınıyor, tabloya değil (B-6).** `AuditReadScope` içinden gelen audit SELECT'i geçiyor; **aynı tabloya** `AuditReadScope` dışından gelen bağlamsız SELECT bekçiye **takılıyor** (iki test). Fırlatmayan politika varyantının açtığı boşluğu kapatan katman budur.
17f. `OfficeAuditFeed` ortam kapsamıyla koşuyor, muafiyet kullanmıyor ve bayrağa dokunmuyor (test — B-7).
17g. Feed'in ürettiği erişim logu satırı `tenant_id` **NULL** ve bayrak **kapandıktan sonra** yazılıyor (test — B-8).
18. `audit_usage` için kullanıcıya dönük **listeleme rotası veya ekranı yok**; `audit_activity` adı `app/`, `config/`, `database/` ve `tests/` altında hiçbir yerde geçmiyor — zorlaması **chunk9 S-14**. *(`docs/reviews/` tarihsel kayıttır, taramanın dışındadır; plan ve AD'lerdeki geçişler yalnız adlandırma notudur.)*
19. `audit_writes` / `audit_access` adları ve modelleri **S-12'nin adlandırılmış allowlist'i dışında** hiçbir dosyada geçmiyor — kasıtlı ihlal build'i kırıyor (**chunk9 S-12**; N-4'ün tek katmanının telafisi). *Allowlist okuma dışındaki meşru bileşenleri de içerir (`AuditWriter`, `SealAuditBatch`, modeller, `config/audit.php`); kriter "yalnız `App\Queries\Audit\**`" demez.*
20. İş akışı ekranları `audit_*` tablolarından beslenmiyor (**chunk9 S-13**).
21. **Zincir bağımsız doğrulanabiliyor:** mühür, batch'in Merkle kökünü ve önceki mührün zincir hash'ini taşıyor; zincir hash'i `sha256(önceki || kök)` olarak dışarıdan yeniden hesaplanabiliyor (test). Tek yaprak kopyalanmıyor (malleability probu).
22. **Tahrifat görünür oluyor:** append-only trigger devre dışı bırakılıp bir satır düzenlendiğinde yeniden hesaplanan kök mühürdekiyle **uyuşmuyor**; bir mührün zincir hash'i düzenlendiğinde `VerifyAuditChain` bunu raporluyor (iki test).
23. **Yayımlanan yük yalnız hash ve sayı:** dış tanığa giden payload'da audit satırı, kişisel veri veya tenant adı yok (test, alan listesi birebir).
24. Tanık yapılandırılmamışken mühür **tanıksız** kalıyor; sahte referans yazılmıyor (test).
25. **Yayım mühürden önce gelir:** tanığa ulaşılamadığında **hiç mühür yazılmıyor** (test); yayım başarılı olup kayıt başarısız olduğunda sonraki koşum aynı halkayı üretiyor ve tanık onu ikinci kez yayımlamıyor (test).
26. **Mühür dondurulmuş:** `audit_stamps` üzerinde DELETE ve zincir alanı UPDATE'i exception üretiyor; `witness_reference`/`archive_path` yazılabilir kalıyor (üç test).
27. **Doğrulama tanıkla karşılaştırıyor:** trigger devre dışı bırakılıp bir mühür düzenlendiğinde `VerifyAuditChain` raporluyor; tanıkta olup yerelde olmayan ve yerelde olup tanıkta olmayan halkalar da bulgu üretiyor (test).
28. **Yaprak satırın tamamını kapsıyor:** append-only trigger devre dışı bırakılıp bir satırın **içerik** kolonu (`new_values`, `user_id`, `event`) değiştirildiğinde yeniden hesaplanan kök mühürdekiyle **uyuşmuyor** ve `VerifyAuditChain` bunu raporluyor (test). *Kriter 22'nin ilk yarısı yalnız `tenant_id` üzerinden sınanıyordu; içerik kolonları hash'in dışındaydı — heyet 135 F-1.*
29. **Delil tablolarının kolon kümesi yaprak kuralına sabitlenmiş:** `audit_writes` ve `audit_access` kolonları `app.audit_leaf_line` gövdesindeki listeyle birebir; tabloya kolon eklemek testi **kırıyor** (şema sorgusu). Kolon eklemek yaprak kuralının sürümlenmesini zorunlu kılar.
30. **Doğrulama kökü satırlardan yeniden hesaplıyor:** `VerifyAuditChain` her mühür için `app.audit_batch_merkle_root(tablo, from_id, to_id)` çağırıp dönen kökü mühürdekiyle karşılaştırıyor; üst sınır olmadan tarihsel partinin kökü hesaplanamadığı için `p_to_id` parametresi zorunludur (test: tahrifat sonrası doğrulama **bulgu üretiyor**).
31. **Kural adı tüketiliyor:** mührün `hash_algorithm` alanı tanınmayan bir kural adı taşıdığında `VerifyAuditChain` onu sessizce geçmiyor, **raporluyor** (test). Kural adı yazılıp okunmazsa versiyonlama dekorasyondur.
32. **Tanık kopyası taze okunuyor:** `published()` her çağrıda uzaktan `fetch` ediyor; yerel klondaki dosyayı elle değiştirmek karşılaştırmayı **yanıltmıyor** (test).
33. **`witnessed_at` uydurulmuyor:** git tanığıyla yayımlanan mühürde `witness_reference` dolu, `witnessed_at` **NULL** (test). Yayımlanmışlık `witness_reference` üzerinden okunuyor; `PublishAuditRoots` telafi taraması aynı halkayı tekrar tekrar yayımlamıyor (test).
34. **Bilinçli kabul — orphan feed'ler:** `OfficeAuditFeed` ve `BackofficeAuditFeed` bu chunk'ta production caller'sızdır; kabul `docs/issues.md`'de kayıtlıdır ve kapanışı chunk6 §6.9 madde 16 ile chunk8 §8.9 madde 16'ya bağlıdır (Sets kararı, 26.07.2026 — heyet 135 açık soru 3).

---

## Chunk 4 — Hassas veri: şifreleme, tenant anahtarı, dosyalar, rıza


**Bağımlılık:** chunk2, chunk3
**Spec dayanağı:** §8.1 (D-1…D-4), §8.2, §8.3, §8.4 (K-1…K-3), §12
**Mimari dayanak:** AD-05, AD-11, AD-16, AD-24
**Bitiş şartı:** §4.8 kabul kriterleri.

> Spec v1.3 sahip kararı: **otomatik veri silme veya anonimleştirme yoktur.** Bu chunk'ta saklama süresi dolduğunda çalışan hiçbir iş yazılmaz.

> **Kapsam notu (implementasyon, 26.07.2026).** §4.1'in aranabilir hash'i `customer_users` / `training_participants` tablolarını gerektiriyor; ikisi de **chunk5**'te doğuyor ve bu chunk yalnız chunk2/chunk3'e bağımlı. **chunk2 emsali uygulandı:** bu chunk **mekanizmayı** eksiksiz teslim eder (`TenantKeyDeriver::searchHash()` — tenant anahtarıyla HMAC, normalizasyon dahil) ve testle kanıtlar; model üzerindeki kolon/cast/otomatik hash bağlantısı tablolarla birlikte chunk5'te kurulur.
>
> Bilinçli olarak **yazılmadı:** kullanılmayan bir "aranabilir hassas alan" trait'i. Hiçbir chunk4 modelinin böyle bir alanı yok; sınıfı şimdi yazmak, tüketicisi olmayan bir sembol (orphan) üretirdi — heyetin chunk3'te tutanağa bağladığı kalıbın aynısı. Mekanizma `TenantKeyDeriver`'da yaşıyor ve **kullanılıyor**.

---

### 4.1 Alan bazlı şifreleme

Şifrelenecek alanlar: TCKN, kimlik bilgileri, SGK sicil ve hizmet verileri, telefon, kişisel e-posta, IBAN ve banka bilgileri.

⚠ **Giriş kimliği e-postasının arama kolu tenant'tan bağımsızdır** (spec §8.1, Sets 27.07.2026 — heyet 145 F-2). Değerin kendisi tenant anahtarıyla şifrelenir; yanındaki `email_hash` ise **kök anahtarla** üretilir, çünkü kimlik doğrulama kaydı tenant'ı bilmeden bulmak zorundadır. Bu, buradaki tenant-anahtarlı `searchHash()` kuralının **adlandırılmış tek istisnasıdır** ve kabul edilmiş artık riski spec §8.1'de yazılıdır.

- Laravel `encrypted` cast.
- **Aranabilirlik için deterministik hash kolonu:** `national_id` (encrypted) + `national_id_hash` (HMAC-SHA256, indeksli). Şifreli alan üzerinde arama çalışmaz; arama hash üzerinden yapılır.
- `national_id_hash` **tenant anahtarıyla** üretilir → aynı TCKN farklı tenant'larda **farklı hash**. Bu, spec §3.2'nin (çapraz tenant eşleme yasağı) veritabanı düzeyindeki karşılığıdır: hash'ler eşleşmediği için çapraz sorgu teknik olarak da imkânsızdır.
- Unique index yalnız `(tenant_id, national_id_hash)` — global unique **yok** (chunk2 §2.9).

### 4.2 Tenant bazlı anahtar

> Bu yapı 3E'ye özel değil; sonraki projelere taşınabilecek şekilde yazılır (Sets, 26.07.2026). Pratikte tek anlamı şu: `app/Support/Crypto/` yalnız `config()` ve PHP'nin kendi fonksiyonlarına dayanır, projeye özel bir tabloya veya iş kuralına bağlanmaz. Ayrı bir paket veya soyutlama katmanı **üretilmez** — normal ve çalışan kod zaten yeterlidir.

AD-05: tenant bazlı anahtar altyapısı **gün bir** kurulur.

**Tasarım:** kök anahtardan tenant başına anahtar **türetilir** (HKDF-SHA256). Tenant başına ayrı anahtar saklanmaz; `derive(root, tenant_id, purpose)` ile hesaplanır.

| Konu | Karar |
|---|---|
| Kök anahtar | `config('app.key')` **değil**, ayrı `TENANT_ROOT_KEY`. Uygulama anahtarı rotasyonu tenant verisini kilitlememeli. |
| Türetme | HKDF-SHA256; salt = tenant id, info = amaç (`field`, `hash`, `file`) |
| Saklama | Kök anahtar yalnız ortam değişkeninde; veritabanında **yok** |
| Sürüm | Şifreli değerin yanında `key_version` tutulur; rotasyon yolu kapanmaz |
| Rotasyon | Bu chunk'ta **yapılmaz**; `docs/issues.md`'ye açık kalem |

Spec §15/2 anahtar yönetimini açık nokta bırakmıştı. **Bu plan türetilmiş anahtarı seçer:** dış KMS operasyonel bağımlılık ekler, offline geliştirmeyi zorlaştırır ve bu ölçekte (yüzler mertebesinde tenant) fayda getirmez. `key_version` sayesinde ileride KMS'e geçiş yolu açık kalır.

`app/Support/Crypto/TenantKeyDeriver.php` — framework'e bağlı değil, karar da vermiyor → `Support` doğru adres.

### 4.3 Özel nitelikli veri

Spec §8.2: adli sicil, sağlık raporu vb. (KVKK m.6).

Bir evrak türünün özel nitelikli olup olmadığı tanım aşamasında işaretlenir (AD-24, Spec 2). Bu chunk'ta **bayrağın etkisi** kurulur:

| Bayrak açıkken | Davranış |
|---|---|
| Erişim | Ayrı izin gerekir (`document.view_special_category` — chunk6) |
| Görüntüleme | **Her görüntüleme erişim logu üretir** (chunk3 §3.5) |
| Saklama süresi | Tanımlıdır (AD-24). **Süre dolduğunda otomatik işlem YOK** — spec v1.3 |

### 4.4 Dosya saklama (D-1…D-4)

Sistemdeki en yoğun hassas veri taşıyıcısı dosyalardır: kimlik fotokopisi, SGK evrakı, diploma, imzalı yoklama taraması, masraf fişi.

**Tablolar:** `documents` (meta veri, tenant-kapsamlı, RLS'li, `Auditable`), `document_versions` (sürümler, hash).

| # | Uygulama |
|---|---|
| D-1 | Dosya içeriği **yüklemeden önce** tenant anahtarıyla (`purpose=file`) şifrelenir, sonra S3-uyumlu depoya yazılır. Yalnız sunucu tarafı şifreleme yeterli sayılmaz. |
| D-2 | Erişim yalnız **süreli, tekil imzalı bağlantı** ile. Varsayılan ömür 5 dk (`config('security.file_link_ttl')`). Kalıcı genel URL **yok**; bucket public erişime kapalı. |
| D-2a | ⚠ **Dosyaya adıyla veya yoluyla erişim hiçbir zaman verilmez** (Sets, 26.07.2026). Ne `original_filename`, ne `storage_path`, ne bucket adresi bir URL'de, rotada veya yönlendirmede yer alır. Erişim daima **tek bir uçtan** (`/document` benzeri) ve **parametreyle** istenir; yetki orada doğrulanır, erişim logu orada yazılır ve dosya cevap gövdesinde döner. Yani yol bilmek erişim vermez — bilinecek bir yol yoktur. Zorlaması **chunk9 S-17**. |
| D-3 | İmzalı bağlantı **üretilirken** yetki + tenant kontrolü yapılır ve **erişim logu yazılır**. Bağlantı kullanıcıya bağlıdır ve tek kullanımlıktır — bağlantıya sahip olmak erişim hakkı vermez. |
| D-4 | Her dosya SHA-256 özetiyle ve sürümlü saklanır. `is_critical` bayraklı belgeler (imzalı yoklama, onaylı teklif snapshot'ı, resmî işlem kaydı) **silinmez** — iptal/revizyonla yönetilir (AD-11). |

**Silme koruması — iki trigger.** `documents` tablosunda `is_critical = true` satırlar için DELETE exception üretir **ve** `document_versions` tablosunda kritik bir belgeye ait her sürüm satırı için de üretir (chunk3 §3.2 trigger deseni). ⚠ İkincisi şart: kanıt yükünü taşıyan şey sürüm satırıdır (`content_hash`, `storage_path`, `key_version`) — yalnız üst satırı korumak, kritik belgeyi doğrulanacak hiçbir şeyi olmayan bir kabuğa indirmeye izin verirdi (heyet 137 F-2).

`app/Support/Files/SecureFileStore.php` — yükleme, imzalı bağlantı üretme, sürüm kaydı.

### 4.5 Rıza kaydı (K-1)

Katılımcı onboarding'inde aydınlatma + açık rıza.

**Tablo `consents`:** tenant-kapsamlı, `Auditable`, silinmez.

| Kolon | Not |
|---|---|
| `consent_text_id`, `consent_text_version` | Hangi metnin hangi sürümü |
| **`locale`** | **Zorunlu.** Rıza belirli bir dilde okunan metne verilir (spec K-1). Kayıt silinemez olduğu için dil sonradan eklenemez — kolon `NOT NULL`. |
| `granted_at`, `customer_user_id`, `ip_address` | |

**Tablo `consent_texts`:** sürümlü metinler; `body` alanı **çevrilebilir `jsonb`** (chunk1 §1.4). Metin değişince eski rızalar geçerliliğini korur; yeni kullanıcılar yeni sürümü onaylar.

Eğitim kaydı görüntü/ses içeriyorsa rıza metni bu kapsamı **açıkça** belirtir (AD-13).

⚠ `locale` alanı `NOT NULL` olduğu için rıza kaydı dilsiz oluşturulamaz — bu, spec kriteri 29'un veritabanı düzeyindeki karşılığıdır.

### 4.6 Prod dışı ortam (K-2)

AD-16: prod verisi ham haliyle prod dışına çıkmaz.

- `deployment/scripts/anonymize-dump.sh` — prod dump'ını PostgreSQL Anonymizer (`anon`, kurulu ve doğrulanmış) ile maskeleyip çıktı üretir.
- Maskeleme kuralları `deployment/postgresql/anon-rules.sql`, tablo/kolon bazında.
- Script **prod'da çalışmayı reddeder** — başında ortam kontrolü.

### 4.7 Üçüncü taraf (K-3)

Kişisel veri üçüncü taraf analitik araçlarına gönderilmez. Uygulamada GA/Mixpanel benzeri istemci tarafı analitik **kurulmaz**; kullanım ölçümü kendi kullanım logumuzdan gelir (chunk3 §3.9). chunk9 §9.5 taraması denetler.

### 4.8 Kabul kriterleri

1. Hassas alanlar veritabanında şifreli (ham SQL ile okunduğunda anlaşılmaz). **Mekanizma bu chunk'ta kanıtlanır** (`CryptoProbeModel` fixture'ıyla: ham kolon değeri okunamıyor, tenant satırdan geliyor, başka tenant'ın anahtarı çözemiyor, tenant'sız satır reddediliyor). ⚠ **Gerçek hassas alan taşıyan tabloya uygulanması chunk5 §5.9'a devredildi** (26.07.2026 — heyet 137 F-3a): `customer_users` / `training_participants` orada doğuyor, index'in "sonraki chunk'a bağımlı kriter yoktur" kuralı gereği kriterin tablo yarısı burada tutulamaz. chunk2 emsali.
2. `national_id_hash` tenant anahtarıyla üretiliyor: aynı TCKN iki tenant'ta **farklı hash** (test).
3. ~~Global unique index yok; unique yalnız `(tenant_id, national_id_hash)`.~~ → **chunk5 §5.9 D-4'e devredildi** (26.07.2026). Kriter `customer_users` tablosunu gerektiriyor, tablo chunk5'te doğuyor; index'in "sonraki chunk'a bağımlı kriter yoktur" kuralı gereği düşürüldü. ⚠ Taşıma değil **tekilleştirme**: chunk5 D-4 ("global unique index yok; unique yalnız `(tenant_id, alan)`") zaten aynı kriterdi. Emsal: heyet 134 F-13.
4. Kök anahtar `TENANT_ROOT_KEY`, `APP_KEY`'den bağımsız (test). `key_version` **dosya tarafında** `document_versions.key_version` kolonunda tutuluyor. ⚠ **Alan tarafındaki `key_version` kolonu chunk5 §5.9'a devredildi** (26.07.2026 — heyet 137 F-3c): sürüm, şifreli değerin bulunduğu **tablonun kolonudur** ve o tablolar chunk5'te doğuyor. Kural: hassas alan taşıyan her tabloya, o alanların yanına `key_version` kolonu konur; `TenantKeyDeriver::version()` yazılacak değeri verir.
5. Özel nitelikli bayrağı `documents.is_special_category` olarak mevcut ve **her belge erişimi erişim logu üretiyor** (test). ⚠ Kriterin **"ayrı izin gerektiriyor"** yarısı `document.view_special_category` iznine dayanıyor; izin altyapısı **chunk6**'da kuruluyor ve o yarı **chunk6 §6.9'a devredildi** (26.07.2026). Bayrağın kayda **kopyalandığı** (tanımdan okunmadığı) bu chunk'ta kuruludur: tanım sonradan değişirse yüklenmiş belge geriye dönük sınıf değiştirmez.
6. Dosya içeriği depoya **şifreli** yazılıyor; bucket'ta public erişim kapalı (yapılandırma kontrolü).
7. İmzalı bağlantı süreli ve kullanıcıya bağlı; süresi dolmuş veya başka kullanıcıya ait bağlantı reddediliyor (test).
8. İmzalı bağlantı üretimi erişim logu yazıyor (test). ⚠ **D-3'ün "yetki + tenant kontrolü" yarısı chunk6 §6.9'a devredildi** (26.07.2026 — heyet 137 F-4): `issueLink()` bugün yetki kontrolü yapmıyor çünkü izin altyapısı chunk6'da. Bu chunk kullanıcı↔bağlantı bağlılığını ve erişim logunu kurar; `document.download` izninden geçme şartı orada sınanır.
9. `is_critical` belge silme girişimi **exception** üretiyor (SQL denemesiyle kanıt).
10. `consents.locale` `NOT NULL`; dilsiz rıza kaydı oluşturulamıyor (test).
11. `consent_texts.body` çevrilebilir; eksik dilde fallback zinciri çalışıyor (test).
12. Anonimleştirme scripti maskelenmiş dump üretiyor ve prod'da çalışmayı reddediyor.
13. Üçüncü taraf istemci analitiği yok (kod taraması).
14. Saklama süresi dolduğunda çalışan silme/anonimleştirme işi **yok** (chunk9 §9.5 taraması).

---

## Chunk 5 — Kimlik ve kimlik doğrulama


**Bağımlılık:** chunk1, chunk2, chunk3
**Spec dayanağı:** §3 (üç kimlik alanı, I-5), §3.0 (U-1…U-5), §3.1, §3.2, §3.3 + §3.3.1 + §3.4 (yetki tavanı / kapılı erişim / kapsam tercihi — **sınır olarak okunur, bu chunk'ta mekanizma üretilmez**), §4.1 (B-1…B-6), §5 (5.1–5.6), §13, kabul kriterleri 31–37
**Mimari dayanak:** AD-07, AD-09, AD-23
**Bitiş şartı:** §5.9 kabul kriterleri.

---

### 5.1 Üç bağımsız kimlik alanı — üç tablo ailesi

Spec §3 (v1.11). **Üçü hiçbir tabloyu paylaşmaz, özellikle yetki tablolarını.**

> **Backoffice ayrı alana çıkarıldı (Sets kararı, 26.07.2026).** Önceki sürümlerde iki alan vardı ve backoffice ofis kimliği üzerinde `system.access_backoffice` iznindi. Gerekçe AD-09 ve spec §3'te: ofis çok net bir yapıdır ve öyle kalmalıdır; backoffice ihtiyaca göre şekillenecek karmaşık yetki yapıları gerektirecektir (destek görevlisi, ticket bazlı kapılı erişim). Karmaşıklığı `users` üzerinde istisna olarak taşımak yerine backoffice'in rutini olarak tanımlamak ofisi denetlenebilir bırakır.

**Ofis tarafı** (tenant'a ait değil, chunk2 §2.8'de bağlamsız):

| Tablo | İçerik |
|---|---|
| `users` | 3E operasyon personeli — direktörler, grup yöneticileri, planlamacı, satışçı, eğitmen |
| `user_sessions` | Aktif oturumlar |
| `user_auth_settings` | Ofis alanı için açık auth yöntemleri |
| `password_reset_tokens` | Şifre sıfırlama (Laravel iskeletinden gelir, e-posta anahtarlı — ofis e-postası benzersizdir) |

**Backoffice tarafı** (tenant'a ait değil, bağlamsız):

| Tablo | İçerik |
|---|---|
| `backoffice_users` | Sistem çekirdeğini yönetenler; bugün süper admin, ileride destek görevlisi gibi **ofis personeli olmayan** roller |
| `backoffice_user_sessions` | Aktif oturumlar |
| `backoffice_auth_settings` | Backoffice alanı için açık auth yöntemleri |
| `backoffice_password_reset_tokens` | Şifre sıfırlama; e-posta anahtarlı (backoffice e-postası benzersizdir). Ofisin `password_reset_tokens`'ı ile **paylaşılmaz** — paylaşılsaydı bir ofis sıfırlama bağlantısı backoffice hesabını da hedefleyebilirdi |

⚠ **Aynı kişi hem ofiste hem backoffice'te kayıt taşıyabilir** ve bu **çakışma değildir** — iki ayrı alan, iki ayrı kimlik. Bir direktörün ikisine de ihtiyacı varsa iki kaydı olur; kayıtlar birbirine **bağlanmaz** (bağlamak, ayrımı iptal etmenin sessiz yoludur).

⚠ **Backoffice MFA'sı zorunludur** ve ayarla kapatılamaz (spec §5.2): bu alanın **yetki tavanı yoktur** (spec §3.3) — sistem çekirdeğini yönetir, gerektiğinde müşteri verisini görür, ofis kimliklerini de burası doğurur.

⚠ **Kapılı erişim bu chunk'ta YAZILMAZ.** Spec §3.3.1 dar yetkili backoffice rollerinin (destek) erişim biçimini tarif eder; mekanizması destek yapısının kendi spec'iyle gelir. Bu chunk'ta backoffice kimliği **tenant-agnostik** çalışır — ama bu bir **yetki tavanı değildir** (spec §3.3): backoffice'in müşteri verisine erişimi bugün yalnız **kapsam tercihiyle** yoktur (spec §3.4), erişemediği için değil. İlerideki kapılı yol için bugün yapılan tek şey, o yolun `users` üzerinden **açılamayacak** olmasıdır.

**Müşteri tarafı** — çoğu tenant-kapsamlı ve RLS'li; ⚠ işaretli iki tablo bağlamsızdır ve chunk2 §2.8'in kesin listesindedir:

| Tablo | İçerik |
|---|---|
| `customers` | Firma bilgileri; `legal_type` (`legal_entity` / `natural_person`), `default_locale`, `timezone` |
| `customer_users` | Müşteri tarafındaki **kişi ve kimlik** — firma yetkilisi, grup yöneticisi, eğitim alan personel; hepsi tek kayıt |
| `customer_user_sessions` | ⚠ **Bağlamsız** (chunk2 §2.8) — satır her istekte, kimlik doğrulanmadan önce okunur; kapsam oturumdan hesaplandığı için RLS dairesel olurdu |
| `customer_auth_settings` | Müşteri alanı için açık auth yöntemleri. ⚠ **Bağlamsız** — ayarın ekseni kimlik alanıdır, tenant değil (spec §5.1) |
| `customer_password_reset_tokens` | Şifre sıfırlama; **e-posta değil `customer_user_id` anahtarlı** — aynı e-posta birden çok tenant'ta bulunabilir (spec §3.2), e-posta anahtarlı bir tablo hangi kaydın sıfırlandığını söyleyemezdi |
| `training_participants` | **Kişi × eğitim.** Yoklama, sınav sonucu, bakanlık sertifika kodu burada. Bu chunk'ta yalnız tablo ve ilişki kurulur; içeriği dikey planlarda |

#### 5.1.0 Tenant'ın kendisini yaratmak — sıradaki tek yazma

`customers` kendi `id`'si üzerinden RLS'lidir, yani **yazılan satır kapsamın kendisidir**. Bu, sistemdeki tek "önce kapsam açılamayan" yazmadır ve implementasyonda iki şey ortaya çıkardı:

1. **Bekçinin üçüncü istisnası.** `TenantContextGuard::whileProvisioningTenant()` — audit yazımı ve backoffice audit okuması gibi, muafiyet **bileşene** verilir ve iki kez daraltılır: yalnız `INSERT`, yalnız `customers`. Tenant'ları geri okumak hâlâ kapsam ister, dolayısıyla bu yol "tüm müşterileri listele"ye dönüşemez.
2. **`RETURNING` kullanılamaz.** PostgreSQL `INSERT … RETURNING`'de SELECT politikasını da uygular; kapsam yokken eşleşecek bir şey olmadığı için canlı veritabanında şu hatayı verir (doğrulandı):

   ```
   ERROR: new row violates row-level security policy for table "customers"
   ```

   Çözüm: id **sequence'ten** alınır (sequence'lerde satır güvenliği yoktur) ve INSERT `RETURNING`'siz yazılır. Reddedilen alternatifler gerekçeleriyle `App\Actions\Tenancy\CreateCustomer` içinde yazılı: SELECT politikasını gevşetmek her bağlamsız okumayı sessizce boş sonuç döndürür hale getirirdi; `SECURITY DEFINER` bir yazıcı ise §2.6.1'in W sözleşmesinin açıkça yasakladığı şeydir (iş verisine yazan fonksiyon).

⚠ **`CreateCustomer` bugün production yolundan çağrılmıyor** (bilinçli kabul edilmiş orphan): müşteri açma ekranı backoffice/ofis işidir ve chunk6 ile gelir. Emsal chunk3'ün `OfficeAuditFeed`'idir; kapanışı **chunk6 kriteri**ne bağlanır.

**P-5 yapısal olarak çözülmüştür:** `users` tenant tablosu olmadığı için `tenant_id IS NULL` istisnası ve onun `WITH CHECK` açığı diye bir şey yoktur.

`customer_users` unique kısıtı: **`(tenant_id, email)`**. Kişi başına tek kayıt olduğu için tür ekseni yoktur (spec §13). `users` unique kısıtı: `(email)`.

Aynı e-postanın `users`, `backoffice_users` ve `customer_users` içinde birden fazlasında bulunması **çakışma değildir** — ayrı tablolar, ayrı kimlik alanları. `backoffice_users` unique kısıtı da `(email)`.

⚠ **E-postaya göre arama `email_hash` eşitliğiyle yapılır** (Sets kararı, 27.07.2026 — heyet 146 açık soru 4). Kolon şifreli olduğu için `where('email', …)` hiçbir zaman eşleşmez; bulma daima tenant kapsamı içinde ve **hash eşitliğiyle** olur. ⚠ **Kısmi/`LIKE` araması bu kolonda yoktur ve olamaz** — şifrelemenin bilinçli bedelidir. Kural yüzey doğmadan yazıldı: yazılmasaydı ilk arama ekranı ya "çalışmıyor" diye geri dönerdi ya da birine düz metin bir yardımcı kolon ekletme baskısı üretirdi, ki bu F-2'nin kapattığı şeyin arka kapıdan dönüşü olurdu. Autocomplete benzeri bir ihtiyaç doğarsa **spec kararı olarak** geri gelir, sessiz bir kolon olarak değil.

⚠ **I-5 (spec §3): kayıtlar birbirine bağlanmaz.** Hiçbir tabloda `user_id ↔ backoffice_user_id` gibi bir kolon, ortak bir `person_id`, ya da "aynı kişi" bilgisini tutan bir eşleştirme tablosu **yoktur**. Zorlaması kriter 23'tür (şema kontrolü).

#### 5.1.1 Üç guard, üç oturum tablosu

`config/auth.php` üç guard ve üç provider taşır: `office` → `users` · `backoffice` → `backoffice_users` · `customer` → `customer_users`. Laravel iskeletinin tek `web` guard'ı kaldırılır; adı belirsiz bir varsayılan guard, yanlış tabloya düşen bir `Auth::user()` çağrısını sessiz kılar.

Oturum tabloları da üçe ayrıldığı için Laravel'in tek `sessions` tablosu **kullanılmaz** (iskeletten gelen migration'da düşürülür). Gerekçe olgusal: veritabanı oturum sürücüsü satırı `user_id` ile bağlar ve üç alanın id'leri **çakışır** — `users.id = 1` ile `backoffice_users.id = 1` aynı satırı işaret edebilirdi. A-2 ("kullanıcı kendi aktif oturumlarını görür ve sonlandırır") bu çakışmayla doğru çalışamaz.

- `App\Support\Auth\DomainSessionHandler` — Laravel'in `DatabaseSessionHandler`'ını genişletir; tabloyu isteğin **kimlik alanından** çözer (alan tespiti host bazlıdır, §5.8).
- Değerlendirilip **reddedilen** alternatif: tek `sessions` tablosu + `identity_domain` kolonu. Daha az kod ama üç alanın kimlik tablosu paylaşmama kuralını (spec §3, kriter 31) delerdi; ayrım tam da bu tür "küçük paylaşımlar"la aşınır.

#### 5.1.2 Şifre sıfırlama — üç ayrı akış

Ofis ve backoffice akışları standarttır: e-posta benzersiz, tablo bağlamsız, doğrudan sorgulanır.

Müşteri akışı bağlam sorunu taşır — sıfırlama isteyen kişi henüz oturum açmamıştır, dolayısıyla tenant bilinmez. Çözüm **yeni bir bağlamsız kapı açmaz**, mevcut olanı kullanır: `app.authenticate_lookup(email)` (chunk2 §2.7) aday kayıtları döndürür; e-posta birden çok tenant'ta varsa **her kayıt için ayrı token** üretilir ve hepsi aynı posta kutusuna gider. Sızıntı yoktur — kayıtların sahibi zaten o posta kutusudur — ve kullanıcıya kaç kaydı olduğu **söylenmez**; e-posta her durumda "bağlantı gönderildi" der.

⚠ Kapının şifre hash'ini de döndürüyor olması bu akışta **kullanılmaz**. İkinci, daha dar bir bağlamsız fonksiyon yazmak spec §4.1 B-3'ün "tek ve denetlenebilir kapı" hükmüne aykırı olurdu: bağlamsız yüzeyi genişletmek, döndürdüğü alanı daraltmaktan daha maliyetlidir. Fonksiyon zaten S-9 envanterindedir.

Token `token_hash` olarak saklanır (magic link ile aynı gerekçe) ve tek kullanımlıktır.

**Token'ı tüketen taraf üçüncü bir dar kapı gerektirdi.** `customer_password_reset_tokens` RLS'lidir ve bağlantıya tıklayan kişi anonimdir — magic link'in birebir aynı durumu. `app.password_reset_lookup(text)` beş alan döndürür ve §2.7'nin **A sözleşmesine** tabidir. Alternatif olan "tenant id'yi sıfırlama URL'sine koymak" reddedildi: kimliksiz bir isteğin kendi tenant kapsamını seçmesi, chunk2 §2.5'in tam olarak önlemek için var olduğu şeydir.

**Sertleştirme — sıfırlama isteği kimliksiz bir uçtur.** A-1 yalnız *giriş* denemelerini kapsar; sıfırlama isteğinin kendi sınırı vardır: `config('security.password_reset_throttle')` — e-posta başına ve IP başına, varsayılan **3 istek / saat**. Gerekçe: her istek posta üretir ve çok-tenant e-postada **kayıt başına** token doğurur; sınırsız uç, posta bombardımanı ve token üretim floodu demektir.

**Postanın içeriği tenant kimliği taşımaz.** Aynı e-posta N tenant'ta kayıtlıysa **N ayrı posta** gider; hiçbiri diğerine atıf yapmaz, hiçbiri firma adı, tenant kimliği veya "başka hesabınız da var" bilgisi taşımaz. Gerekçe I-1/I-2 ile aynı sınıftır: posta kutusunun kontrolü doğrulanmamıştır ve kutuya erişen kişi kayıt sahibi olmayabilir; tek postada birleştirilmiş bağlantılar, sistem eliyle üretilmiş bir **çapraz-tenant ilişki artifact'ı** olurdu — *"bu adresin X ve Y firmalarında hesabı var"*. Kullanıcı hangi kaydı sıfırladığını bağlantıya tıkladıktan **sonra**, kendi oturumu içinde görür.

### 5.2 Katmanlı yetenek (U-1…U-5)

Spec §3.0. Müşteri tarafında "panel kullanıcısı" ve "katılımcı" **ayrı hesap değildir**.

| Yetenek | Nereden gelir |
|---|---|
| **Katılımcılık** | **Veriden türer** — kişinin `training_participants` kaydı varsa |
| **Panel yetkisi** | Firma tarafından **açıkça verilir** (izin ataması) |

**Uygulama:**

- `CustomerUser::isParticipant()` — `training_participants` ilişkisinin varlığı. Bir izin değildir, sorgudur.
- Panel yetkileri chunk6'daki izin altyapısından gelir.
- **Menü ve sayfa erişimi yetkiden türer** (spec §3.0.1, Y-5): panel yetkisi yoksa firma paneli rotalarına **URL ile de** erişilemez — policy reddeder, `ModelNotFoundException` veya 403 döner (chunk6 §6.5 kuralına göre).
- Katılımcı ekranları `isParticipant()` false ise render edilmez ve erişilemez (U-2).
- Yetki verme = mevcut `customer_users` kaydına izin eklemek. **Yeni kayıt açılmaz, veri taşınmaz** (U-3, U-4).
- Yetki verme/kaldırma `Auditable` üzerinden audit üretir **ve** kişiye bildirim gönderir (U-5) — bildirim chunk7'de bağlanır; bu chunk'ta olay yayınlanır (`CustomerUserPermissionsChanged`).

⚠ **P-4'ün çözümü:** bu chunk'ın kabul kriterleri yalnız bu chunk'ta üretilen şeyleri sınar. Bildirimin gerçekten gittiği chunk7'nin kriteridir; burada yalnız **olayın yayınlandığı** sınanır.

### 5.3 Auth yöntem matrisi ve sertleşme

Spec §5.1. **Ayarların ekseni kimlik alanıdır** (ofis / backoffice / müşteri); **üç alan bağımsız ayarlanır**.

- `user_auth_settings`, `backoffice_auth_settings` ve `customer_auth_settings`: `(method, is_enabled)`. Kodda sabit yöntem–alan eşlemesi **yok** (AD-08).
- Varsayılanlar seed ile: ofis → `password` + `two_factor`; backoffice → `password` + `two_factor`; müşteri → `password` + `magic_link`.
- ⚠ Backoffice'te yöntem matrisi de admin-tanımlıdır (AD-08), ama §5.7'nin **alan bazlı zorunlu MFA**'sı matristen bağımsız olarak üstte durur: tek başına ikinci faktör taşımayan bir yöntemle backoffice oturumu tamamlanamaz.
- Kapalı yöntemle giriş reddedilir.
- Yöntem kapatıldığında **açık oturumlar geçerliliğini korur**, yeni giriş yapılamaz (spec §5.1).

**Yetenek bazlı sertleşme kuralı** (spec §5.1, §5.3): yöntemin açık olması, o yöntemle her ekrana erişilebileceği anlamına gelmez.

- `app/Http/Middleware/RequireStrongAuth.php` — panel yetkisi gerektiren rotalarda, oturum magic link ile açılmışsa **şifre/2FA ile yeniden doğrulama** ister.
- Oturumda `auth_method` ve `authenticated_at` tutulur.
- Katılımcı ekranları magic link oturumuyla erişilebilir kalır.

**Gerekçe** (spec §5.3): magic link paylaşılabilir bir bağlantıdır; paylaşıldığında teklif onaylama veya personel yönetme yetkisinin devrolması kabul edilemez.

### 5.4 Magic link

`magic_links` tablosu: `token_hash`, `customer_user_id`, `tenant_id`, `expires_at`, `used_at`, `purpose`.

| Kural | Uygulama |
|---|---|
| Tek kullanımlık | `used_at` dolduysa reddedilir |
| Süreli | Varsayılan 30 dk, `config('security.magic_link_ttl')` |
| Token hash'li saklanır | Ham token yalnız e-postadaki bağlantıda; veritabanı sızıntısı oturum açmaya yetmez |
| **Bootstrap kapısına ihtiyaç duymaz** | Kayıt zaten `tenant_id` taşır (spec §4.1 B-6) — tenant token'dan bilinir |
| **Link istemek de akışın parçasıdır** | `POST /baglanti` (`RequestMagicLink`): şifre sıfırlamayla **aynı şekil** — kayıt başına ayrı link, kayıt başına ayrı posta, firma adı geçmez, cevap her durumda aynı, hız sınırı ortak (`AnonymousRequestLimiter`). Yalnız tüketen tarafı yazmak yöntemi ayarda açılabilir ama ulaşılamaz bırakırdı |
| **Ek kimlik teyidi zorunlu, kapatılamaz** | Bağlantı tıklandıktan sonra ikinci adım (varsayılan: TCKN son 4 hane). **Hangi bilginin sorulacağı** admin ayarı, **varlığı** ayar değil (spec §5.3) |
| Katılım kanıtı değildir | Yoklama akışı magic link oturumuna bakmaz (dikey plan) |
| Panel yetkisini açmaz | §5.3 |

### 5.5 Şifreli giriş ve bootstrap

Ofis girişi: `users` bağlamsız tablodur, doğrudan sorgulanır (`office` guard).

Backoffice girişi: `backoffice_users` de bağlamsızdır, doğrudan sorgulanır (`backoffice` guard). Bootstrap kapısına ihtiyaç duymaz — tenant aranmaz, çünkü backoffice kimliği hiçbir tenant'a ait değildir. ⚠ Bu, backoffice'in **giriş** akışıyla ilgilidir; girdikten sonra tenant verisine erişebilmesi (spec §3.3) ayrı bir konudur ve bu chunk'ta üretilmez.

Müşteri girişi: **chunk2 §2.7'deki `app.authenticate_lookup` kapısı** kullanılır. Akış:

1. Kullanıcı e-posta + şifre girer.
2. `authenticate_lookup(email)` → `id, tenant_id, password_hash, status` (yalnız bu dört alan).
3. Şifre doğrulanır, hesap durumu kontrol edilir.
4. Başarılıysa `TenantContext::run([$tenantId])` açılır; kullanıcının tam kaydı **normal kural altında** okunur.
5. Başarılı/başarısız her deneme audit'lenir (spec B-5).

⚠ Aynı e-posta birden fazla tenant'ta bulunabilir (spec §3.2). Bu durumda `authenticate_lookup` birden çok satır döndürür; **şifre eşleşen tek kayıt** seçilir. Hiçbiri eşleşmezse veya birden fazlası eşleşirse giriş reddedilir ve olay audit'lenir. Kullanıcıya hangi tenant'ta hesap olduğu **bilgisi verilmez**.

### 5.6 Oturum ve hesap güvenliği

| # | Uygulama |
|---|---|
| A-1 | Başarısız giriş hız sınırı; eşik ve kilit süresi `config('security.login_throttle')` — varsayılan 5 deneme / 15 dk. **Spec §15/4 açık nokta**: karar gelince ayar değişir, kod değişmez |
| A-2 | Kullanıcı aktif oturumlarını görür ve tek tek sonlandırır |
| A-3 | Yeni cihaz/IP girişi olay yayınlar (`SuspiciousLoginDetected`); bildirim chunk7'de |
| A-4 | Şifre değişimi, MFA aç/kapa, e-posta/telefon değişimi → **audit** + olay yayını |
| A-5 | Kullanıcı **pasifleştirilir, silinmez** (AD-23) |
| A-5a | **Pasifleştirme anında etki eder** (spec A-5a, Sets 27.07.2026). Durumu `active` olmaktan çıkaran işlemin **kendisi** o kimliğin **bütün** oturum satırlarını siler — üç alanın her birinde, hangi cihaz olursa olsun (`TerminatesSessions`). Kuyruğa alınmaz: "az sonra çalışacak bir iş" tam olarak bu maddenin kapatmak istediği aralığı bırakır. ⚠ **Oturum satırı kimlik bilgisinin kendisidir**; silinince sonraki istek herhangi bir middleware'e bağlı olmadan anonim olur. `EnsureAccountIsActive` **ikinci katmandır** ve artık `web` grubunda + Filament panelinde global çalışır — rota grubuna eklenmesi unutulabilecek bir yerde değil. ⚠ **Anlık bağlantı ayağı chunk8'in borcudur** (§8.2.1): WebSocket açık kaldığı sürece kişi hiçbir istek atmadan veri almaya devam eder |
| A-6 | Oturum ve hareketsizlik süresi **kimlik alanı bazında** ayarlanabilir — üç ayar tablosunun (`user_auth_settings`, `backoffice_auth_settings`, `customer_auth_settings`) her birinde bağımsız |

### 5.7 Zorunlu MFA

Spec §5.2 iki ayrı zorunluluk tanımlar; ikisi farklı katmanda yaşar.

**(a) Yetki bazlı — ofis ve müşteri alanları.** Teklif onaylama, e-fatura, kullanıcı yetkisi değiştirme, mali tablo görüntüleme yetkilerinden **herhangi birine** sahip kullanıcı için MFA zorunludur; admin kaldıramaz.

- İzin adları `config('security.mfa_required_permissions')`.
- Zorlama **chunk6'daki yetki katmanında**: MFA kurulu değilse bu izinler **etkisizdir** ve kullanıcıya kurulum uyarısı gösterilir.
- Bu chunk'ta MFA kurulum/doğrulama akışı ve `two_factor_confirmed_at` alanı üretilir; **zorlamanın testi chunk6'nın kriteridir** (P-4).

**(b) Alan bazlı — backoffice'in tamamı.** Backoffice kimlik alanındaki **her** kullanıcı için MFA zorunludur (spec §5.2, AD-23); yetkiye bakılmaz, ayarla kapatılamaz.

- Zorlama **bu chunk'tadır** ve yetki katmanını beklemez: `backoffice` guard'ının oturum tamamlama adımı `two_factor_confirmed_at` boşsa oturumu açmaz, kullanıcıyı MFA kurulumuna yönlendirir. Kurulum tamamlanmadan backoffice'te hiçbir rotaya erişilemez.
- Kod kuralıdır: `backoffice_auth_settings` içinde MFA'yı kapatan bir kayıt **etkisizdir**; ayar tablosu yöntemi seçer, zorunluluğu kaldıramaz.
- Gerekçe (spec §3.3): bu alanın yetki tavanı yoktur ve ofis kimliklerini de burası doğurur — "henüz yetki almamış backoffice kullanıcısı" diye zayıf bir aralık bırakılamaz.
- Testi bu chunk'ın kriteridir (kriter 24), chunk6'ya devredilmez — (a)'dan farkı budur.

### 5.8 Panel ayrımı ve Filament

- Oturum çerezi **host bazında** ayrılır; `SESSION_DOMAIN` isteğin host'undan türetilir. Bir alanın oturumuyla diğerinin paneline girilemez.
- **Host, kimlik alanını ve guard'ı birlikte belirler** — tek kaynak, üç satır:

| Host / giriş noktası | Kimlik alanı | Guard | Oturum tablosu |
|---|---|---|---|
| `ofis.*` | Ofis | `office` | `user_sessions` |
| `backoffice.*` | Backoffice | `backoffice` | `backoffice_user_sessions` |
| `/panel`, `/katilimci` | Müşteri | `customer` | `customer_user_sessions` |

- Alan tespiti `App\Http\Middleware\ResolveIdentityDomain` içinde tek yerde yapılır; guard, oturum tablosu (§5.1.1) ve auth ayar tablosu (§5.3) hep buradan çözülür. Üç yerde ayrı ayrı host kontrolü yapılmaz — üçünün ayrışması kolayca sessiz bir yanlış-alan hatası üretirdi.
- Filament paneli `backoffice.*` host'unda ve **yalnız `backoffice` guard'ıyla** çalışır; `users` tablosuna hiç bakmaz. Kapı bir ofis izni değil, **`backoffice_users` kaydının kendisidir** (spec §3.3, AD-09). MFA §5.7(b) gereği zorunludur. Filament'in kendi auth'u kullanılmaz; uygulamanın guard'ı kullanılır.
- ⚠ `system.access_backoffice` izni **kaldırılır** — eski iki-alan modelinin kalıntısıdır ve bugün yanlış bir şeyi ima eder: backoffice'e giriş artık ofis kimliği üzerinde bir izin değildir. chunk6 izin listesinden de çıkarılır.

### 5.9 Kabul kriterleri

> **Chunk2'den devralınanlar (Sets onayı, 26.07.2026).** Aşağıdaki maddeler chunk2 §2.12'den buraya taşındı: o kriterler `customers`, `customer_users` ve `magic_links` tablolarını gerektiriyor, tabloların tanımı ise bu chunk'ta. Chunk2 mekanizmayı ve şablonları teslim etti; burada tablolar doğduğu anda uygulanırlar.
>
> | # | Devralınan kriter |
> |---|---|
> | D-1 | `customers` tablosunda `id` bazlı politika var; INSERT `WITH CHECK (true)`, DELETE politikası **yok** (`TenantPolicy::applyToCustomers()`) |
> | D-2 | `app.magic_link_lookup()` yalnız altı alan döndürüyor; `app.all_customer_ids()` **geçici** olarak işaretli ve S-9 envanterinde |
> | D-3 | `app.authenticate_lookup()` yalnız dört alan döndürüyor; `customer_users` RLS'li kalmış |
> | D-4 | `customer_users` tablosunda global unique index yok; unique yalnız `(tenant_id, alan)` |
> | D-5 | Test R-12 (aynı TCKN iki tenant'ta, kayıtlar birbirini görmüyor), R-14 (`authenticate_lookup` dört alan), R-16 (`magic_link_lookup` altı alan) |


1. `users`, `backoffice_users` ve `customer_users` ayrı tablolar; ilk ikisi tenant-kapsamlı **değil**, `customer_users` RLS'li (şema kontrolü).
2. Aynı e-posta üç tabloda da aynı anda bulunabiliyor; çakışma üretmiyor (test).
3. Aynı e-posta aynı tenant'ta ikinci `customer_users` kaydı olarak eklenemiyor; farklı tenant'ta eklenebiliyor ve kayıtlar ilişkilendirilmiyor (test).
4. `isParticipant()` `training_participants` varlığından türüyor; izin değil (test).
5. **Eğitim kaydı olmayan kişi katılımcı ekranlarını görmüyor ve erişemiyor** (U-2, test — izin altyapısı gerektirmez, veriden türer).
6. Katılımcıya panel yetkisi verildiğinde yeni `customer_users` kaydı açılmıyor ve veri taşınmıyor (U-3, U-4, test — yalnız kayıt sayısı ve veri bütünlüğü sınanır).
7. *(U-1 panel erişim reddi ve step-up doğrulama testleri chunk6'ya taşındı — izin altyapısı orada kuruluyor.)*
8. *(U-5'in kriteri **chunk6 §6.9'a taşındı** — implementasyonda görüldü: "yetki değişikliği" izin altyapısı olmadan gerçekleşemez ve o altyapı chunk6'nındır. Burada olayı yayınlayan bir eylem yazmak, tüketicisi olmayan bir sembol üretmek olurdu — chunk3/chunk4 tutanağındaki ilke. Emsal: kriter 7 ve 11 aynı gerekçeyle taşınmıştı.)*
9. Auth yöntem matrisi **kimlik alanı bazında** tablodan okunuyor; kapalı yöntemle giriş reddediliyor (test).
10. Yöntem kapatılınca açık oturum düşmüyor, yeni giriş yapılamıyor (test).
11. *(Step-up doğrulama testi chunk6'ya taşındı.)* Bu chunk'ta `RequireStrongAuth` middleware'i ve oturumdaki `auth_method` alanı üretilir; davranış testi chunk6 §6.9'dadır.
12. Magic link ikinci kullanımda ve süresi dolduğunda reddediliyor; token veritabanında hash'li.
13. Magic link sonrası ek kimlik teyidi ayarla **kapatılamıyor** (test).
14. Müşteri girişi `authenticate_lookup` üzerinden yapılıyor; fonksiyon yalnız dört alan döndürüyor (test).
15. Aynı e-posta iki tenant'ta varken şifre eşleşen tek kayıt seçiliyor; hiçbiri/birden fazlası eşleşirse giriş reddediliyor ve audit üretiliyor (test).
16. Başarılı ve başarısız kimlik doğrulama denemeleri audit üretiyor (test).
17. **Pasifleştirilen kullanıcının bütün açık oturumları o anda siliniyor** — iki cihazda açık oturumu olan kullanıcı donduğunda iki satır da gidiyor, başka kullanıcıların oturumlarına dokunulmuyor, ve üç kimlik alanında da aynı şekilde çalışıyor (test; spec A-5a). Anlık bağlantı ayağı chunk8 §8.2.1'in kriteridir.
18. Ofis oturumuyla müşteri paneline erişilemiyor (test).
19. Filament paneline **yalnız `backoffice_users` tablosundan** ve MFA kurulu kullanıcı girebiliyor (test). Ofis kullanıcısı — yetkisi ne olursa olsun — `backoffice.*` host'unda oturum açamıyor. *(`system.access_backoffice` dizgesinin kod tabanında hiç geçmediği **chunk6 kriter 2a**'da sınanır — tek yer; buraya da yazmak aynı kontrolü iki kapanışa böler, F-13 emsali.)*

*(20–26: v1.11 — üçüncü kimlik alanı. Spec kriterleri 31–37'nin bu chunk'a düşen yarısı. Kalanların adresleri: **kriter 31**'in izin yarısı ve **32** chunk6 (§6.9 kriter 2, 2c) · **34** chunk3 §3.6.1 · **37** chunk9 **S-19** taraması · **33** ölçülecek yüzeyi olmadığı için plan index'in **devir kuralları** tablosunda — heyet 143 F-1.)*

20. **Üç alan hiçbir kimlik tablosu paylaşmıyor** — kimlik, oturum, auth ayarı ve şifre sıfırlama tabloları ayrı; Laravel iskeletinin tek `sessions` tablosu düşürülmüş (şema kontrolü; spec kriter 31'in kimlik yarısı — izin tabloları chunk6'nın kriteridir).
21. **Üç guard bağımsız çalışıyor**: her guard yalnız kendi tablosunu sorguluyor; varsayılan `web` guard'ı yapılandırmada yok (test + config kontrolü).
22. **Bir alanın oturumu diğerinin host'unda geçersiz** — ofis oturumuyla `backoffice.*`, backoffice oturumuyla `ofis.*` açılamıyor; üç yönlü test.
23. **Kayıtlar ilişkilendirilmiyor (I-5)**: aynı e-postayla ofis ve backoffice kaydı bir arada duruyor; şemada iki kaydı bağlayan kolon veya eşleştirme tablosu yok ve birinden diğerine yetki/oturum taşınmıyor (şema kontrolü + test; spec kriter 36).
24. **Backoffice alanında MFA zorunlu ve ayarla kapatılamıyor**: `two_factor_confirmed_at` boş olan backoffice kullanıcısı oturum tamamlayamıyor, `backoffice_auth_settings` üzerinden kapatma denemesi etkisiz (test; spec kriter 35).
25. **Auth yöntem matrisi üç tabloda bağımsız**: bir alanda kapatılan yöntem diğer alanları etkilemiyor (test; kriter 9'un üç-alan karşılığı).
26. **Müşteri şifre sıfırlama tenant belirsizliğini doğru çözüyor**: aynı e-posta iki tenant'ta varken iki ayrı token üretiliyor, her token yalnız kendi kaydını sıfırlıyor ve kullanıcıya kaç kaydı olduğu bilgisi verilmiyor (test).
26a. **Sıfırlama postası tenant kimliği taşımıyor**: iki kayıt için iki ayrı posta gidiyor, hiçbiri firma adı/tenant kimliği veya diğer kayda atıf içermiyor (test — posta gövdesi sınanır).
26b. **Sıfırlama isteği hız sınırlı**: e-posta ve IP başına eşik aşıldığında istek reddediliyor ve posta üretilmiyor (test).
27. **Magic link istenebiliyor**: `POST /baglanti` kayıt başına ayrı link üretiyor, her posta ayrı gidiyor ve hiçbiri firma adı taşımıyor; kayıtlı olmayan adres de aynı cevabı alıyor (test). *(Implementasyonda eklendi: yalnız tüketen tarafı yazmak yöntemi ulaşılamaz bırakıyordu.)*
28. **Audit aktör türü kimlik alanını ayırıyor**: backoffice eylemleri `backoffice_user` olarak yazılıyor ve ofis feed'inde görünmüyor (test). *(Implementasyonda ortaya çıktı: üçüncü alan `ActorType`'a değmeden eklenirse backoffice eylemleri `system`'e düşer ve ofis feed'i onları gösterir — §7.5'in izin vermediği şey.)*

> **chunk4'ten devralınanlar (26.07.2026 — heyet 137 F-3).** Hassas alan mekanizması chunk4'te kuruldu ve fixture'la kanıtlandı; **tabloya bağlı** iki kriter buraya taşındı:
>
> | # | Devralınan |
> |---|---|
> | D-6 | Hassas alanlar (`national_id` vb.) gerçek tabloda şifreli saklanıyor; ham SQL ile okunduğunda anlaşılmıyor (chunk4 kriter 1'in tablo yarısı). ⚠ **Şifreli kolona yalnız model attribute yolundan yazılır.** Şifreleme bir cast'tir; `query()->update()` / `DB::table()->update()` gibi yazmalar onu **atlar** ve düz metni sessizce kolona indirir. Zorlaması **chunk9 S-16** taramasıdır (Sets kararı, 26.07.2026 — heyet 138 açık soru 1); kural yazılıp zorlanmazsa "yazıldı ama tutulmuyor" sınıfına girer. ⚠ S-16 `config('crypto.encrypted_columns')` listesine güvenir; **listenin gerçeği yansıttığını chunk9 S-18 senkron kontrolü** garanti eder (Sets kararı, 26.07.2026 — heyet 139) |
> | D-7 | Hassas alan taşıyan her tabloda, alanların yanında `key_version` kolonu var ve `TenantKeyDeriver::version()` değeriyle dolduruluyor (chunk4 kriter 4'ün alan yarısı). ⚠ **Şifreli kolonun tipi her zaman `text`'tir, `string` değil.** Laravel'in şifreleme zarfı — base64(JSON{iv, value, mac}) — değerden çok daha büyüktür: ölçüldü, 15 karakterlik bir değer 200, 40 karakterlik 256 karakter olur. `varchar(255)` sıradan bir girdide taşar ve hata **yazma anında** gelir. TCKN'nin bugün sığması tesadüftür, kural değil (heyet 139 F-r3-1b) |

---

## Chunk 6 — Yetki zorlama, görünürlük, referans kodları


**Bağımlılık:** chunk2, chunk3, chunk5
**Spec dayanağı:** §3 (üç kimlik alanı → üç izin ailesi, kriter 31'in izin yarısı), §6 (Y-1…Y-6), §4.4 (V-1…V-5), §9 (R-1…R-10), §5.2
**Mimari dayanak:** AD-09, AD-19, AD-23
**Bitiş şartı:** §6.9 kabul kriterleri.

> **Kapsam sınırı:** organizasyon ağacı, roller, kapsam seviyeleri ve onay zincirleri **Spec 3'ün** konusudur. Bu chunk yalnız **zorlama iskeletini** kurar; Spec 3 geldiğinde iskelet doldurulur, yeniden yazılmaz.

---

### 6.1 spatie/laravel-permission yapılandırması

v8.3.0. **`teams` özelliği kapalı** (`config/permission.php` → `'teams' => false`).

Gerekçe: paketin teams'i rolü tek bir takıma bağlar. Spec 3'ün modelinde kişi **birden fazla grupta farklı rollerle** bulunabilir ve kapsam ağaç üzerinden çözülür; paket bunu taşıyamaz. Zorlarsak sökülmesi pahalı bir yanlış soyutlama kurmuş oluruz. Grup–rol ilişkisi kendi tablomuzda yaşar (Spec 3).

**Üç ayrı yetki dünyası** (spec §3, v1.11): `users`, `backoffice_users` ve `customer_users` **aynı izin tablolarını paylaşmaz**. Spec kriter 31 bunu şema kontrolüyle zorlar — *"kimlik, oturum, auth ayarı **ve izin tabloları** ayrı"*.

- Ofis izinleri: spatie'nin standart tabloları, `model_type = App\Models\User`.
- Backoffice izinleri: **ayrı tablolar** — `backoffice_permissions`, `backoffice_roles`, `backoffice_user_permissions`, `backoffice_user_roles`, `backoffice_role_permissions`. Bağlamsız (chunk2 §2.8).
- Müşteri izinleri: **ayrı tablolar** — `customer_permissions`, `customer_roles`, `customer_user_permissions`, `customer_user_roles`, `customer_role_permissions`. Tenant-kapsamlı ve RLS'li: bir firmanın rol tanımı başka firmaya görünmez.

⚠ **Plan düzeltmesi (27.07.2026, implementasyon sırasında):** aile başına **dört** tablo yazılmıştı; doğrusu **beş**. Eksik olan rol↔izin pivotu (`role_has_permissions` ve aile karşılıkları) — rolün izin taşıması onsuz mümkün değil, yani rol kavramının kendisi çalışmıyordu. Bağlamsız iki ailenin pivotu `config('tenancy.exempt_tables')`'a eklendi (33 → 35), müşterininki tenant-kapsamlı ve RLS'li.

⚠ **Pivot tablolarında `tenant_id` varsayılanı `app.write_tenant_id()`.** spatie'nin `attach()` INSERT'ünü kendi kuruyor ve `tenant_id`'yi bilmiyor; `BelongsToTenant` o satırda hiç çalışmaz. Varsayılan kolaylık değil kapı: fonksiyon, kapsamda tam olarak bir tenant yoksa hata veriyor — yani yazma bağlamı olmadan atılan pivot satırı tahmin edilmiş bir tenant'la düşmüyor, yüksek sesle patlıyor.

Gerekçe iki yönlüdür. Müşteri tarafında: firma **kendi** yetki yapısını kurar (spec §3.0, çatı §9); bunu 3E'nin izin tablolarıyla paylaşmak iki dünyayı birbirine bağlar ve bir taraftaki hata diğerine sızar. Backoffice tarafında: ofis **çok net bir yapıdır ve öyle kalmalıdır**, backoffice ise ihtiyaca göre şekillenecek karmaşık yetki yapıları gerektirecektir (spec §3 gerekçesi) — ortak tabloda bu karmaşıklık ofisin izin ailesine istisna olarak sızardı; ayrımın kurulma sebebi tam olarak budur.

⚠ **Backoffice rol ailesi bu chunk'ta dar başlar:** bugün tek rol vardır — süper admin, sınırsız. Dar roller (ileride destek görevlisi) ve onların spec §3.3.1'deki kapılı erişimi **bu chunk'ın işi değildir**; destek yapısının kendi spec'iyle gelir. Burada üretilen şey ailenin **ayrı** olmasıdır, içinin dolması değil.

#### 6.1.1 Üç aile nasıl işletilir

Aileleri ayırmak şema kararıdır; **çalıştırmak** ayrı bir iştir ve planda hiç yazılmamıştı (heyet 147 F-6).

| # | Kural |
|---|---|
| A-1 | **Her ailenin kendi `Gate` bağlaması vardır.** İzin kontrolü, soranın kimlik alanına göre o alanın tablolarına gider; bir alanın izni diğerinin `Gate` kontrolünde **hiçbir zaman** sonuç üretmez. Bağlama tek yerde yapılır (`AuthServiceProvider`), üç ayrı `before`/`can` kancasıyla değil — üç kanca üç ayrı yerde yaşlanır |
| A-2 | **Müşteri ailesinin izin sorgusu tenant kapsamı içinde koşar.** `customer_permissions` / `customer_roles` RLS'lidir; kapsam açılmadan sorgulanamaz (AD-04: yalnız `SET LOCAL`, transaction içinde). Yani izin kontrolü bir **iş biriminin içindedir** — `TenantContext::run()` sarmalını izin katmanı kendi açar, çağıranından beklemez |
| A-3 | **İzin cache'i tenant başına anahtarlanır.** spatie'nin cache'i global tek anahtar kullanır; tenant-kapsamlı bir izin ailesiyle bu, bir firmanın rol tanımını başka firmaya servis etmek demektir. Cache anahtarı aktif tenant'ı taşır; kapsam yokken müşteri izin cache'i **hiç kullanılmaz** (okunacak bir şey de yoktur). Ofis ve backoffice aileleri bağlamsız olduğu için tek anahtarla devam eder |
| A-4 | **Tenant'ın rol tanımları tenant doğarken doğar.** Global bir seed RLS'li tabloda sonraki tenant'ları kapsayamaz — seed koştuğunda o tenant henüz yoktur. Varsayılan müşteri rolleri, müşteri oluşturulurken aynı iş biriminde yazılır; "seed" kavramı bu aile için **kurulum anına** değil **tenant açılışına** bağlıdır |

⚠ A-4'ün sonucu: `CreateCustomer` bugünkü hâliyle eksik — tenant'ı yaratıyor ama rol tanımlarını yaratmıyor. **Kapatıldı (27.07.2026):** `ProvisionTenantAuthorization` müşteri satırıyla **aynı transaction'da** koşuyor; roller olmadan doğan bir firmayı kimse yönetemez ve düzeltecek kişi henüz giremeyen kişidir, o yüzden ya ikisi de olur ya hiçbiri.

⚠ **Varsayılan rol kararı bekliyor (Sets'e soruldu, 27.07.2026).** Plan hangi rollerin doğacağını yazmıyor. Bugün `config('authorization.customer.default_roles')` **tek** rol tanımlıyor: `owner`, beş müşteri iznini de taşıyor. Gerekçe: ilk gün firmayı birinin yönetebilmesi gerekir ve o yetkiyi verecek başka kimse yoktur. Firma bunu yeniden adlandırabilir, daraltabilir, tamamen değiştirebilir — satırlar onların.

### 6.2 İzin adlandırma

Format `resource.action` (kapsam seviyesi Spec 3'te eklenecek). **İngilizce** (dil kuralı).

**Ofis izinleri (omurga):**
```
user.view · user.create · user.update · user.deactivate · user.assign_permissions
user.assign_customers · customer.view_all
customer.create · customer.update · customer.deactivate
document.view · document.upload · document.download · document.view_special_category
audit.view
analytics.view
financial.view
```

⚠ `audit.view_access_log` bu listeden **çıkarıldı** (heyet 147 F-11): spec §7.5 erişim logunu ofiste **"Görünmez"** diye yazar, dolayısıyla ofis ailesinde ona karşılık gelen bir izin olamaz — izin varsa er ya da geç birine verilir. Erişim logu backoffice'in işidir ve karşılığı orada `audit.view_access_log` olarak durur.

⚠ `system.access_backoffice` ve `system.manage_settings` de bu listeden **çıkarıldı** (spec v1.11). Birincisi eski iki-alan modelinin kapısıydı; backoffice'e giriş artık bir ofis izni değil, `backoffice_users` kaydının kendisidir (chunk5 §5.8). İkincisi bir backoffice işidir ve backoffice izin ailesine aittir.

**Backoffice izinleri (omurga):**
```
settings.view · settings.update
regulation.view · regulation.create · regulation.update · regulation.deactivate
training.view · training.create · training.update · training.deactivate
document_type.view · document_type.create · document_type.update · document_type.deactivate
backoffice_user.view · backoffice_user.create · backoffice_user.update
backoffice_user.deactivate · backoffice_user.assign_permissions
office_user.view · office_user.create · office_user.update · office_user.assign_permissions
audit.view_all_tenants · audit.view_access_log
```

> **Revizyon — Sets kararı, 27.07.2026: sözlük EYLEME göre bölündü.** Backoffice ailesi `system.manage_regulations` gibi tek izinlerle yazılmıştı — okuma ve yazma aynı izinde. İki sakıncası vardı. Birincisi ifade edilemiyordu: *"kataloğu okur, değiştiremez"* diye bir rol tanımlanamıyordu, oysa ofisin çoğu böyledir. İkincisi ekrandaydı — her izin aynı biçimde göründüğü için **bakma yetkisi vermek isteyen, yazma yetkisi verebiliyordu**; satırlar fark etmesini sağlayacak hiçbir şey sunmuyordu. Eylem sözlüğü sabittir: `view` · `create` · `update` · `deactivate` · `assign_permissions`. Bir kaynakta o eylem yoksa **izin de yoktur**; ekran o hücreyi "—" olarak çizer, uydurmaz.
>
> Aynı revizyonda iki gerçek kusur kapandı: (a) `BackofficeUserPolicy::update` ve `UserPolicy::update` `assign_permissions` ile cevap veriyordu — birinin e-postasını düzeltmek, yetki dağıtma hakkı gerektiriyordu; ayrı `*.update` izinleri açıldı. (b) `user.provision` / `user.assign_permissions` backoffice adları `office_user.*` oldu — **aynı adın iki ailede iki anlam taşıması** artık açıklanması gereken bir tuzak değil, olmayan bir durum.

⚠ `office_user.*` izinleri **backoffice ailesindedir**: ilk ofis kimliğini backoffice doğurur ve ofis hesaplarını sonrasında da yönetebilir (spec §3.3, AD-23 kural 1'in kalıcı istisnası). ⚠ Backoffice ailesinde `office_user.deactivate` **yoktur** — hesabı hizmet dışı bırakmak ofisin kendi kararıdır ve kendi ailesinde durur.

⚠ **Kullanıcı yönetimi iki tarafta da vardır** *(spec v1.12, Sets 27.07.2026 — planın önceki metni "backoffice doğurur ve yönetir" diyerek bunu kapatıyordu)*. Operasyon direktörü, ekleme yetkisi verilmişse ofis personeli ekler; satış direktörü, verilmişse satışçı ekler. Ofis günlük işleyişte kendi kadrosunu backoffice'e sormadan kurar. Ayrım tavandadır: backoffice **kendi taşımadığı** ofis yetkisini atayabilir (Y-4'ün adlandırılmış istisnası), ofis tarafında aynı iş Y-4'e tabidir. Ofis ailesindeki `user.create` / `user.deactivate` / `user.assign_permissions` bu yüzden vardır ve süs değildir.

**Müşteri izinleri (omurga):**
```
customer.manage_organization · customer.manage_people
customer.request_quote · customer.approve_quote
customer.view_documents
```

İzinler **seed ile** veritabanına gelir; kodda sabit izin listesi yoktur (AD-08). Yeni izin eklemek migration/seed işidir.

### 6.3 Atlanamazlık (Y-1, Y-2, Y-3)

1. **Policy zorunluluğu.** `AuthServiceProvider` boot'unda: `BelongsToTenant` kullanan her model için policy kaydı var mı kontrol edilir; eksikse `RuntimeException`. Policy'siz model üretilemez.
2. **Arch testi** (`tests/Arch/AuthorizationTest.php`): `app/Actions/**` içindeki her sınıf ya `authorize()` çağırır ya `#[NoAuthorizationRequired]` attribute'u taşır (gerekçesiyle). Attribute'suz ve yetkisiz Action testi kırar.
3. **Yetkisiz erişim audit'lenir** (Y-3): `Gate::after` reddedilen kontrolü **hatırlar**, satır `AuthorizationException` anında `audit_writes`'a yazılır.

   ⚠ **Plan düzeltmesi (27.07.2026, implementasyon sırasında).** Plan *"`Gate::after` kancasıyla reddedilen kontroller yazılır"* diyordu; öyle yapılırsa **gizlenen her menü kalemi bir audit satırı üretir** — menü görünürlüğü de Gate'e sorar ve bir sayfa render'ı onlarca kez sorar. Günde binlerce "görünmeyen menü" satırı, gerçek bir denemeyi anlatan bir avuç satırı gömer. Ayrım şudur: `Gate::allows` bir **soru**, `AuthorizationException` birini **durdurmuş** bir rettir; audit'lenen ikincisidir. `Gate::after` yine gerekli — istisna hangi yetkinin reddedildiğini taşımıyor (`App\Support\Authorization\RefusedChecks`).
4. **Menü ve sayfa** (Y-5, spec §3.0.1): yetkisiz menü hiç render edilmez **ve** rotaya URL ile erişilemez. Menü gizleme güvenlik değildir; kapı policy'dir.

### 6.4 MFA zorunluluğunun zorlanması

Spec §5.2 **iki** MFA zorunluluğu tanımlar ve ikisi ayrı katmanda yaşar. Bu bölüm yalnız birincisidir.

**(a) Yetki bazlı — ofis ve müşteri. Zorlaması BURADA, P-4'ün çözümü.**

- `config('security.mfa_required_permissions')` listesindeki izinlerden birine sahip kullanıcı MFA kurmamışsa, `Gate` kontrolü o izinler için **false** döner.
- Kullanıcıya kurulum uyarısı gösterilir (arayüz metni `lang/`).
- Admin bu zorunluluğu kaldıramaz — ayar değil, kod kuralıdır.

**(b) Alan bazlı — backoffice'in tamamı. Zorlaması chunk5'te YAPILDI, burada tekrarlanmaz.**

Backoffice kimlik alanındaki her kullanıcı için MFA zorunludur; yetkiye bakılmaz (spec §5.2, AD-23). chunk5 §5.7(b) bunu guard/panel seviyesinde kurdu ve kriter 24'le sınadı — **yetki katmanını beklemez**, çünkü "henüz yetki almamış backoffice kullanıcısı" tam da zayıf bırakılmaması gereken aralıktır. Buraya ikinci bir kontrol eklemek aynı kuralı iki kapanışa böler (F-13 emsali).

### 6.5 Kapsam çözümleyici

Spec 3'ün ağaç modeli henüz yok; iskelet şimdi kurulur.

`app/Domain/Authorization/ScopeResolver.php` — **arayüz**. Bugün **tek** metot taşır:

```
accessibleTenantIds(identity): int[]
```

⚠ **`scopeLevel(identity, permission)` bu chunk'ta eklenmez** (düzeltme 27.07.2026, heyet 147 F-5). Plan onu arayüzün parçası gibi yazıyordu; teslim edilen arayüzde yok ve olmaması doğru: kapsam **seviyesi** (`own` / `group` / `all`) organizasyon ağacının kavramıdır ve ağaç Spec 3'ün ürünüdür. Bugün eklenirse `SessionScopeResolver`'ın verebileceği tek dürüst cevap `null` olurdu — yani her çağıranın ayrıca ele alması gereken bir "bilmiyorum", ki bu arayüzü genişletmenin en kötü hâlidir. Metot **Spec 3 turunda**, onu cevaplayabilen implementasyonla birlikte gelir.

**Bugünkü implementasyon `SessionScopeResolver`'dır** (chunk2'de yazıldı, chunk5'te kullanıldı): müşteri kimliği → oturumdaki tenant; ofis kimliği → **boş liste**; backoffice kimliği → boş liste.

⚠ **Plan düzeltmesi (27.07.2026).** Bu bölüm önceden `SimpleScopeResolver` adında, ofis kimliğine **tüm tenant'ları** veren bir geçici implementasyon tarif ediyordu. Öyle bir sınıf yazılmadı ve yazılmamalı: chunk2'nin teslim ettiği çözümleyici ofis için bilinçli olarak boş döner ve gerekçesi kendi dosyasında yazılı — *"her tenant'ı döndüren bir vekil, bir kapsam hatasının çalışıyormuş gibi görünerek production'a ulaşmasına izin verirdi"*. İki metin aynı anda doğru olamazdı; **kod haklı**, plan düzeltildi.

**Sonucu açıkça yazmak gerekiyor:** ofis kimliği bugün **hiçbir tenant verisine erişemez** ve erişmeye çalışan sorgu yüksek sesle düşer. Bu bir eksiklik değil, sıralama kısıtıdır — omurga iş ekranı üretmediği için bugün kimseyi bloklamaz, ama **ilk dikey modül Spec 3'ün organizasyon ağacını gerektirir**. P-7'nin prod çıkış kısıtı bu yüzden daha da nettir: kısıt "yanlış kapsamla çıkmayalım" değil, "kapsam henüz yok"tur.

**Backoffice kimliği** de boş döner. ⚠ Bu bir yetki tavanı **değildir** (spec §3.3): backoffice'in tenant verisine erişim yolu bugün yalnız **üretilmemiştir**; üretildiğinde (destek spec'i) çözümleyiciye kendi dalıyla girer.

⚠ **P-7'nin çözümü — prod çıkış kısıtı (gerekçesi 27.07.2026'da düzeltildi):**

> ~~**`TreeScopeResolver` (Spec 3) devreye girmeden hiçbir dikey modül gerçek müşteri verisiyle production'a çıkarılamaz.**~~
>
> ⛔ **KISIT KALKTI — gerekçesi ortadan kalktığı için** *(07.08.2026, EGT-34 ölçümü)*. Kısıt şu olguya dayanıyordu: *"ofis kimliği bugün hiçbir tenant'a çözülmüyor, dolayısıyla müşteri verisine dokunan bir modül çalışmaz."* **§6.5.2 aynı gün bunu çözdü** (Sets kararı, 27.07.2026): izin + atama deseni kuruldu ve `SessionScopeResolver` ofis kimliği için gerçek bir kapsam döndürüyor — `customer.view_all` varsa hepsi, yoksa `office_user_customers`'taki atamalar.
>
> ⚠ **İki metin aynı dosyada kaldı ve biri diğerinin ayağını çekti**: 07.08.2026'da bu satır okunup *"prod çıkış kısıtı kırmızı"* diye alarm verildi, oysa aşağıdaki §6.5.2 onu zaten kapatmıştı. Bayat kısıt, olmayan bir engeli var gösterir.
>
> ⚠ **Eksik kalan şey `TreeScopeResolver` değil, GRUP AĞACIDIR**: kapsamın kaynağını kişi bazlı atamadan organizasyon ağacına taşımak (`own`/`group`/`all` seviyeleri). O-4 gereği arayüz değişmez, bugün yazılan hiçbir ofis ekranı yeniden yazılmaz. Bugünkü ölçekte (50 personel) elle atama yönetilebilir; ağaç Spec 3 / EGT-23 turunda gelir. *(Eski gerekçe "geçici çözüm ofis kimliğine tüm tenant'ları verir" diyordu; öyle bir çözüm yazılmadı ve yazılmayacak — yukarıdaki plan düzeltmesi.)*
>
> Kısıt `docs/issues.md`'ye açık kalem olarak yazılır ve chunk9 §9.6 rollout kontrol listesinde yer alır.

`SessionScopeResolver` başına yorum: *"Temporary. Replaced by TreeScopeResolver (Spec 3). Do not add business rules here."*

### 6.5.2 Ofis kapsamı — izin + atama deseni

**Sets kararı, 27.07.2026.** Ofis kimliğinin boş kapsamı, her turda başka bir işi erteleten tek sebepti. Kapsam **şimdi** kuruluyor — ve tek seferlik bir çözüm olarak değil, **ofis yetki yapısının tamamının çalışacağı desen** olarak.

**Desen — eksen başına iki parça:**

```
X.view_all            izin (ofis ailesi)     -> hepsi
office_user_X         atama tablosu          -> yalnız atananlar
```

İzin varsa kullanıcı o eksenin tamamını görür; yoksa yalnız kendisine atananları. Karar **admin'indir** (AD-08: iş kuralı koda gömülmez): operasyon direktörü ya da backoffice süper admini, bir kullanıcıya `view_all` verir ya da vermeyip atama yapar.

**Sets'in tarif ettiği kullanım** (birebir): *"operasyon direktörü kendine operasyon personeli alabilir, ona da aynı yetki kırılımı ile yaklaşabilir. Mali tabloları göremez, sadece atanmış müşterilerle ilgili işlem yapabilir, sadece atanmış satışçılarla ilgili işlem yapabilir diyebilir. Ya da hepsini der devam eder."*

| Eksen | İzin | Atama tablosu | Nerede |
|---|---|---|---|
| **Müşteri** | `customer.view_all` | `office_user_customers` | **Bu chunk** — omurganın ihtiyacı |
| Satışçı | `sales_user.view_all` | `office_user_sales_users` | Satış modülü ile |
| Mali görünürlük | `financial.view` | — (eksen değil, ikili yetki) | Muhasebe modülü ile (`62 Yönetici Muhasebe`) |

⚠ **Bu chunk yalnız müşteri eksenini kurar.** Diğerleri aynı desenle kendi modülleriyle gelir; desen burada yazılı olduğu için o gün **yeni bir şekil icat edilmez**.

| # | Kural |
|---|---|
| O-1 | `SessionScopeResolver` ofis kimliği için: `customer.view_all` **varsa** tüm müşteriler, **yoksa** `office_user_customers`'taki atamalar |
| O-2 | Bu, chunk2'nin haklı olarak reddettiği *"tüm tenant'lar"* vekili **değildir**. Fark: orada reddedilen şey **kodda** herkese sessizce her şeyi veren bir vekildi; burada `view_all` belirli bir kullanıcı için **açıkça verilmiş, audit'li bir yetki kaydıdır**. Operasyon direktörü zaten onu taşır — ofis tarafının en geniş yetkilisi odur |
| O-3 | İzni vermek **ve** atama yapmak audit'lenir: kimin kapsamına hangi müşterinin girdiği kritik işlemdir (spec §7.1, kırmızı çizgi 13) |
| O-4 | **Spec 3 geldiğinde `ScopeResolver` arayüzü değişmez.** Grup bazlı kapsam (AD-03'ün *"İzmit satış grubu"*) atama tablosunu **doldurur** ya da onun yerine geçer; değişen kapsamın **kaynağıdır**, tüketicisi değil. Bugün yazılan hiçbir ofis ekranı o gün yeniden yazılmaz |
| O-5 | ⚠ **Planlamacının müşteri seçimi kapsam sorusu değildir.** Belirli bir müşteri veya davet grubu için eğitim planlarken planlamacı o müşterileri **seçim listesinde görür, detayını göremez** (Sets). Bu, dar bir okumadır (`App\Queries` içinde kimlik alanları) ve `customer.view` izninin yokluğuyla sınırlanır — kapsam mekanizmasına yeni bir dal eklemez |

### 6.5.1 Bu chunk hangi yüzeyi üretir, hangisini üretemez

Kriterlerin sınayacağı yüzeyler planda hiç tarif edilmemişti (heyet 147 F-7). Ofis kapsamının boş olması bunu bir tercih meselesi olmaktan çıkarıyor:

| Alan | Bu chunk | Gerekçe |
|---|---|---|
| **Backoffice** | ✅ backoffice kullanıcı yönetimi, `backoffice_user.*` ile kapılı | Bağlamsız; kapsam sorunu yok. Kapsam sınırı *"backoffice'in omurga ekranları"nı* zaten istisna sayıyor. Kriter 2b'nin yüzeyi budur |
| **Müşteri** | ✅ panel yetkisi kontrolü, step-up, yetki atama yolu | Müşteri kapsamı oturumdan geliyor (chunk5). Kriter 7b, 7c, 15 |
| **Ofis** | ✅ **kullanıcı yönetimi + sistem logu** — tasarımdaki `61 Yönetici Tanımlar` ve `63 Yönetici Kullanıcı/Log` modüllerinin omurga çekirdeği | Ofis kapsamı §6.5.2 ile **çözüldü**; yüzeyler tasarımda zaten tanımlı. Kriter 16 ve kriter 32'nin ofis bacağı burada kanıtlanır |

⚠ **Tasarım handoff'u modül haritası olarak okunur, kompozisyon olarak değil** (CLAUDE.md): hangi modüllerin var olduğu bağlayıcıdır, sayfa kompozisyonu / alan sıralaması / ekran sayısı değildir. Buradan alınan tek şey *"ofiste kullanıcı yönetimi ve sistem logu vardır"* bilgisidir.

⚠ **Önceki taslak bu üç satırı 'ofis üretilemez' diye yazıyordu ve kriter 16 ile 32'nin ofis bacağını Spec 3'e erteliyordu.** Geri alındı (Sets, 27.07.2026): erteleme *"ofis yüzeyi bilinmiyor"* varsayımına dayanıyordu, oysa modül haritası eksiksiz duruyor; gerçek eksik **kapsamdı** ve o §6.5.2 ile kapandı.

### 6.6 Görünürlük kümesi (V-1…V-5)

Spec §4.4: eğitim kaydının görünürlüğü **türünden** türer.

`app/Domain/Visibility/VisibilityRule.php` — saf sınıf. Girdi: kayıt türü + davet listesi + soran kimlik. Çıktı: görünür / görünmez.

| Tür | Görünürlük |
|---|---|
| `open` | Tüm tenant'lar |
| `corporate` | Yalnız sahibi firma |
| `corporate_closed` | Yalnız davet edilen firmalar |

- Üç kural sabit kodlanmaz; tür → strateji eşlemesi `config('visibility.rules')`.
- Eğitim entity'si dikey planda gelir; burada **mekanizma** kurulur.

**"Bulunamadı" davranışı (V-3, T-1, R-8):** görünmeyen veya kapsam dışı kayda erişim **`ModelNotFoundException`** üretir — `AuthorizationException` değil. Aksi halde kaydın varlığı sızar.

`app/Support/Http/NotFoundOnDenied.php` tek noktadan uygular.

⚠ **Ayrım:** *kaydın varlığı gizlenmeliyse* 404; *kayıt görünür ama eylem yasaksa* 403. Örnek: müşteri kendi teklifini görür ama onaylayamıyorsa 403; başka firmanın teklifine erişirse 404.

### 6.7 Referans kodları (R-1…R-10)

`app/Domain/Shared/ReferenceCode.php` — saf sınıf.

| Kural | Uygulama |
|---|---|
| R-1 | Format `PREFIX-YYYY-XXXX`. Alfabe **`23456789BCDFGHJKMNPQRSTVWXYZ`** — rakam 0/1, harf O/I/L ve **sesli harfler** hariç. ⚠ *Düzeltme 27.07.2026 (heyet 147 F-10): önceki dizge `L` içeriyordu, yani kendi kuralını ihlal ediyordu. Testin dizgeyi buradan alması bu hatayı kalıcılaştırırdı — kriter 12 alfabeyi **kurala karşı** doğrular (yasaklı karakterlerin hiçbiri yok), dizgeyi kopyalayarak değil.* |
| R-2 | Rastgele üretim (`random_bytes` tabanlı); ardışık sayaç **yok** |
| R-3 | `reference_codes` tablosuna yazılır, unique index `(code)`. Çakışmada yeniden üretilir (en fazla 5 deneme, sonra exception). Kod **serbest bırakılmaz** — iptal edilen kaydın kodu tabloda kalır |
| R-4 | Prefix registry `config('reference_codes.prefixes')`: `EG`, `TK`, `FT`. **İki harf zorunlu**; üç harfli prefix doğrulama ile reddedilir (YouTrack kodlarıyla karışmasın) |
| R-5 | **Kod yetki değildir.** Kodla erişim policy + kapsam kontrolünden geçer |
| R-6 | Bakanlık sertifika no ve KUMS eğitim ID'si bu tabloya **girmez**; ayrı alanlardır |
| R-7 | `app/Queries/FindByReferenceCode.php` — tam kod eşleşmesi |
| R-8 | Kapsam dışı kod → **"bulunamadı"** |
| R-9 | Koda göre arama erişim logu **üretmez**; erişilen kayıt hassassa görüntüleme chunk3 §3.5 kapsamındadır |
| R-10 | Kod detay ekranlarında görünür ve kopyalanabilir (UI dikey planlarda) |

`reference_codes` tablosu bağlamsızdır (chunk2 §2.8) — benzersizlik global olmalı. Ama koda karşılık gelen **kayda** erişim yine policy'den geçer.

### 6.8 Yetki değişikliği bildirimi

chunk5 §5.2 U-5: yetki verme/kaldırma audit'lenir ve kişiye bildirilir.

- Bu chunk `CustomerUserPermissionsChanged`, `UserPermissionsChanged` **ve `BackofficeUserPermissionsChanged`** olaylarını **dinler** ve audit yazar.
- ⚠ Üçüncüsü 27.07.2026'da eklendi (heyet 147 F-8): yetki değişikliği spec §7.1'in saydığı kritik işlemlerdendir ve **audit'siz kritik işlem kırmızı çizgidir** (13). İki olay tanımlayıp üçüncü alanı atlamak, denetimi en geniş yetkili alanda eksik bırakmak olurdu — üstelik ofis kimliklerini doğuran alan orası.
- Backoffice audit satırları `backoffice_user` aktör türüyle yazılır (chunk5) ve ofis feed'inde **görünmez** (spec §7.5).
- **Bildirimin gönderilmesi chunk7'nin işidir**; burada yalnız audit ve olay zinciri kurulur.

## 6.10 Arayüze giden kimlik (spec T-8)

**Sets kararı, 27.07.2026.** Tarayıcı kayıt id'sini hiç görmez; oturuma özel bir değerle sarmalanmış bir token görür.

`App\Support\Http\OpaqueId` — tek AES bloğu, **22 karakter**: 8 bayt id + türden türetilmiş 8 bayt, birlikte şifrelenip base64url'e çevrilir.

⚠ **Uzunluk burada süs değil, ölçü.** Token'lar seçim listelerinde taşınır ve satır başına ödenir. 16 bayt bir blok şifrenin tabanıdır; daha kısası küçük uzayda **format-preserving encryption** ister (FF1/FF3), PHP çekirdeğinde yoktur ve kendi Feistel'imizi yazmak bu projenin işi değildir. 50 satırlık bir picker ~1 kB token eder; sunucu tarafında harita olmadığı için mekanizmanın tüm bütçesi budur.

İlk tasarım 48 karakterdi (GCM, nonce + tag). İki parça **getirdiğinden fazlasına mal olduğu için** düşürüldü:
- **Nonce** token'ı her çağrıda değiştiriyordu, yani aynı listede aynı müşteriyi gösteren iki satır alakasız görünüyordu. Bu hiçbir şeyi korumaz: picker müşterinin **adını** zaten yanında yazar. Determinizm ayrıca liste ile submit edilen formun ek defter tutmadan anlaşmasını sağlar.
- **Ayrı GCM tag'i** yerine bütünlük **bloğun içinde** taşınıyor: düz metnin yarısı kayıt türünden türetilir, dolayısıyla sahte bir token eşleşmeyen bir tür yarısına çözülür — 2⁶⁴'te bir kaza. Tek blok ayrıca akış şifresi gibi **oynanabilir değildir**: saldırgan ciphertext'in bir bitini çevirip id'yi bir artıramaz, ki id uzayında yürümeyi mümkün kılacak saldırı tam olarak oydu.

| # | Kural |
|---|---|
| U-1 | **Eşleme tablosu yoktur.** Token id'nin kendisidir, oturumdaki anahtarla şifreli. 500 satırlık liste 500 token eder ve **tek bayt sunucu durumu** tutmaz; sızacak bir harita da olmaz — ki o harita, kaldırmak istediğimiz korelasyonu geri getirirdi |
| U-2 | Anahtar oturum **verisinde** durur, oturum **id'sinden türetilmez**. `regenerate()` id'yi değiştirip veriyi koruduğu için, girişten önce açılmış bir sayfa submit edildiğinde kırılmaz |
| U-3 | **Oturum içinde deterministik**: aynı kayıt aynı token'ı üretir. Liste ile form ek defter tutmadan anlaşır; farklı görünmenin koruyacağı bir şey yoktur, çünkü ad zaten ekrandadır |
| U-4 | Çözülemeyen token (kurcalanmış, başka oturumdan, başka tür) `null` döner ve çağıran bunu **"böyle bir kayıt yok"** gibi işler — çünkü öyledir |
| U-4a | **Yalnız kanonik kodlama kabul edilir.** 16 baytın base64'ünde son karakter iki bit taşır, dolayısıyla birkaç farklı dizge aynı bloğa çözülür; zararsızdır ama bir kaydın birden çok geçerli token'ı olması demektir ve *"kurcalanan token reddedilir"* iddiasını yalnızca **çoğunlukla** doğru kılardı. Çözülen baytlar yeniden kodlanıp girdiyle karşılaştırılır — kayıt başına tam olarak bir token |
| U-5 | **Kalıcı bağlantılar bu mekanizmayı kullanmaz.** E-posta bağlantısı, yer imi, basılı belge oturumsuzdur; onlar kayda özel ayrı bir tanımlayıcı taşır ve açıldığında kimlik doğrulama + yetki kontrolünden geçer. ⚠ Referans kodu **bu iş için kullanılmaz**: rastgele kısmı dört karakter (28⁴ ≈ 614 bin), yani tahmin edilebilir — kod belgede okunan **iş referansıdır**, bağlantı kimliği değil |
| U-6 | **Backoffice kapsam dışıdır** (Sets): Filament kendi URL'lerini kayıt anahtarından üretir ve o panel yalnızca sistemin en geniş yetkisini taşıyan hesapların ulaştığı dar bir yüzeydir |

⚠ **Bunun kapatmadığı şey açıkça yazılıdır:** seçim listesi müşteri **adını** gösterir, çünkü insan adı okuyup seçer. Token, not almanın mekanik ve paylaşılabilir yolunu kapatır; insanın kendi eliyle ad yazmasını değil. Onun cevabı **kapsam daraltmadır** (§6.5.2) — sızıntının yaşandığı olayda asıl kusur da kişinin *tüm* müşterileri görebilmesiydi.

### 6.8.1 Bu chunk'ın bilinçli orphan'ları

⚠ Üç sınıf omurga kapsamında **production caller'sız** doğuyor ve bu kayda geçmeden kabul edilemez (heyet 147 F-13; emsal chunk3'ün `OfficeAuditFeed`'i ve chunk5'in `CreateCustomer`'ı — Sets kararı + `docs/issues.md` + kapanış kriteri üçlüsü):

| Sınıf | Neden caller'sız | Kapanış |
|---|---|---|
| `App\Domain\Visibility\VisibilityRule` | Görünürlük kuralı **eğitim kaydına** uygulanır; eğitim entity'si dikey planda doğar | İlk eğitim modülü |
| `App\Domain\Shared\ReferenceCode` | Kod **bir kayda** üretilir; kod alan kayıtlar (teklif, eğitim, fatura) dikey planlarda | İlk kod taşıyan kayıt |
| `App\Queries\FindByReferenceCode` | Arama yüzeyi yok (R-7, devir tablosunda) | İlk arama ekranı |
| `App\Support\Shared\ReferenceCodes` | Kod **bir kayda** üretilir; kod alan ilk kayıt dikey planda doğar. Üretici burada duruyor ki üç dikey modül aynı benzersizlik mantığını üç kez yazmasın | İlk kod taşıyan kayıt |
| `App\Actions\Documents\IssueDocumentLink` | Belge indirme ekranı yok; **izin kapısı** burada doğuyor (kriter 18) çünkü chunk4'ün dosya deposu onsuz açıktı | İlk belge ekranı |

⚠ **27.07.2026'da iki satır eklendi.** İlk üçü planlanmış orphan'lardı; bu ikisi implementasyon sırasında doğdu ve kayda geçmeden kabul edilemezdi (aynı kural). `App\Support\Http\NotFoundOnDenied` ve `App\Domain\Visibility\VisibilityRule`'un aksine bunlar **bilinçli** üretildi: birincisi benzersizliği veritabanına bırakan tek doğru yolu, ikincisi kriter 18'in kapısını taşıyor.

**Neden yine de burada üretiliyorlar:** üçü de saf kural sınıfı ve testleri kendi başına anlamlı (kriter 11, 12, 13, 14). Dikey plana bırakmak, aynı kuralı üç ayrı modülde üç kez yazma riskini doğururdu — omurganın var olma sebebinin tersi. ⚠ Ama bu, orphan olmadıkları anlamına gelmez: **kayıt burada, kapanış adresi yukarıda.**

### 6.9 Kabul kriterleri

1. `permission.teams` kapalı; grup–rol ilişkisi paketin teams'ine bağlanmamış.
2. **Üç izin ailesi ayrı** — ofis, backoffice ve müşteri hiçbir izin tablosu paylaşmıyor (şema kontrolü; spec kriter 31'in izin yarısı). Müşteri izin tabloları tenant-kapsamlı ve RLS'li: bir firmanın rol tanımı başka firmaya görünmüyor (test).
2d. **Müşteri izin cache'i tenant başına anahtarlanıyor** (A-3): bir tenant'ın izinleri ısıtıldıktan sonra başka tenant kapsamında yapılan kontrol o cache'ten cevap almıyor (test). ⚠ Bu, ayrı tabloların tek başına yetmediği yerdir — cache global kalırsa izolasyon uygulama belleğinde delinir.
2e. **Müşteri izin kontrolü tenant kapsamı içinde koşuyor** (A-2): kapsam açılmadan yapılan bir müşteri izin kontrolü bağlam hatası veriyor, sessizce `false` dönmüyor (test). Sessiz `false` "yetkisi yok" gibi okunur ve gerçek sebebi gizler.
2a. **`system.access_backoffice` kod tabanında hiç geçmiyor** (dizge kontrolü) — backoffice'e giriş bir ofis izni değil, `backoffice_users` kaydının kendisidir (chunk5 §5.8, spec v1.11). **Tek yer burasıdır**; chunk5 kriter 19 buna atıf yapar.
2f. **Y-4 ve Y-5 ofis alanında zorlanıyor** (spec kriter 32'nin ofis bacağı): yetkisiz ofis kullanıcısı kullanıcı yönetimi ve sistem logu rotalarını menüde görmüyor ve URL ile erişemiyor; sahip olmadığı bir ofis iznini devredemiyor (Y-4).
2g. **Ofis kapsamı izin + atama deseninden geliyor** (§6.5.2): `customer.view_all` izni olan kullanıcı tüm müşterileri görüyor; izni olmayan yalnız `office_user_customers`'ta atananları görüyor, ataması yoksa hiçbirini. İzin verme ve atama audit üretiyor (test).
2h. **Yeni müşteri, `customer.view_all` taşıyan kullanıcıya atama gerekmeden görünüyor** (test) — desenin ikili doğasının kanıtı: izin bir *kural*, atama bir *liste*; kuralı taşıyan listeye bakmaz.
2b. **Y-4 ve Y-5 backoffice alanında da zorlanıyor** (spec kriter 32'nin üçüncü bacağı): izinsiz bir backoffice kullanıcısı **backoffice kullanıcı yönetimi kaynağını** (§6.5.1) menüde görmüyor ve rotasına URL ile erişemiyor; sahip olmadığı bir backoffice iznini başka bir backoffice kullanıcısına atayamıyor. *(Bugün ailede tek rol olması testi engellemez — izinsiz kullanıcı bugün de oluşturulabilir; §6.3'ün mekanizmaları alan-agnostiktir ama kanıt üç alanda ayrı ayrı istenir.)*
2c. **Ofis ve backoffice ailelerindeki aynı adlı izinler karışmıyor**: backoffice'in `user.assign_permissions`'ı ofis hesaplarını yönetir ve kapsam sınırı taşımaz; ofisinki ofis içi devri yönetir ve kapsama tabidir. Bir ailedeki izin diğer ailenin `Gate` kontrolünde sonuç üretmiyor (test).
3. Omurga izinleri seed ile veritabanında; kodda sabit izin listesi yok.
4. Policy'si olmayan tenant-kapsamlı model boot'ta exception üretiyor (kasıtlı ihlalle kanıt).
5. Yetkisiz veya attribute'suz Action arch testini kırıyor (kasıtlı ihlalle kanıt).
6. Reddedilen yetki kontrolü audit üretiyor (test).
7. **MFA kurulu olmayan kullanıcıda `mfa_required_permissions` listesindeki izinler etkisiz** (test — P-4 kriteri burada).
7b. **Panel yetkisi olmayan kişi firma paneli rotasına URL ile eriştiğinde reddediliyor** (spec U-1 — chunk5'ten taşındı; izin altyapısı burada).
7c. **Magic link oturumundayken panel yetkisi gerektiren rotada şifre/2FA isteniyor; katılımcı ekranları erişilebilir kalıyor** (spec §5.3 step-up — chunk5'ten taşındı).
7d. Müşteri kimliği `audit.view` izniyle NULL tenant'lı audit satırlarını göremiyor (chunk3 §3.6 N-2). **Bu kriterin tek yeri burasıdır** — chunk3'te 11b olarak çift yazılmıştı ve orada `customer_users` (chunk5) ile izin katmanı (chunk6) olmadığı için sınanamıyordu; 26.07.2026'da düşürüldü (heyet 134 F-13). Politika chunk3'te kurulu, kanıtı burada üretilir.
8. `ScopeResolver` arayüzü mevcut ve **tek metotlu** (`accessibleTenantIds`); `scopeLevel` bu chunk'ta eklenmemiş (§6.5). Bugünkü implementasyon `SessionScopeResolver` geçici olarak işaretli ve `docs/issues.md`'de açık kalem.
9. **Prod çıkış kısıtı** `docs/issues.md`'de ve chunk9 rollout kontrol listesinde yazılı.
10. Görünmeyen/kapsam dışı kayda erişim **`ModelNotFoundException`** üretiyor; görünür ama yasak eylemde 403 dönüyor (iki ayrı test).
11. Görünürlük stratejisi config'ten okunuyor; üç tür de doğru sonuç veriyor (test).
12. Referans kodu: alfabede yasak karakter yok, üç harfli prefix reddediliyor, çakışmada yeniden üretiliyor, iptal edilen kod serbest bırakılmıyor (test).
13. Kapsam dışı kodla arama "bulunamadı" dönüyor (test).
14. 10.000 kod üretim testi: çakışma yönetiliyor, alfabe dışı karakter yok.
15. Yetki değişikliği audit üretiyor ve olay zinciri kuruluyor. **Müşteri tarafında** yetki verme/kaldırma `CustomerUserPermissionsChanged` olayını yayınlıyor; mevcut `customer_users` kaydına izin ekleniyor, **yeni kayıt açılmıyor ve veri taşınmıyor** (U-3, U-4, U-5). *(chunk5 kriter 8'den taşındı, 27.07.2026: izin altyapısı burada doğuyor, orada yalnız tüketicisi olmayan bir sembol üretilebilirdi. Bildirimin gerçekten gitmesi chunk7 kriteridir.)*
15a. *(**Düşürüldü** — Sets kararı, 27.07.2026. `CreateCustomer` kayıtlı orphan olarak kalır; kapanışı **Spec 3 / ilk dikey modül** turundadır ve adresi plan index'in devir tablosundadır.*
    *Gerekçe: ofis kapsamı Spec 3'e kadar boş olduğu için bugün üretilecek ekran müşteriyi **yaratır ama listeleyemez** — ekranı iki kez yazmak demekti.*
    *⚠ **Panel kararı şimdiden çivilendi (Sets): müşteri açma OFİSİN işidir ve ofis ekranında yapılır.** Backoffice'e konması spec §3.4, kriter 37 ve chunk9 S-19 ile çatışır; erteleme, o kararın açık kalması anlamına gelmez.)*
16. **`OfficeAuditFeed` production yolundan çağrılıyor** — `audit.view` izniyle kapılı bir ofis uç noktasından (tasarımdaki `63 Yönetici — sistem logu` modülünün omurga çekirdeği), reverse-grep caller sayısı > 0. *(chunk3'te orphan olarak bilinçli kabul edilmişti; kapanışı buraya bağlıydı. 27.07.2026'da bir ara Spec 3'e ertelendi ve **aynı gün geri alındı**: erteleme ofis kapsamının boş kalacağı varsayımına dayanıyordu, o kapsam §6.5.2 ile kuruldu.)*
17. **Özel nitelikli belge ayrı izin gerektiriyor:** `documents.is_special_category` bayrağı açık bir belgeye erişim `document.view_special_category` izni olmadan reddediliyor (test). *(chunk4 §4.3 bayrağı ve erişim logunu kurdu; iznin kendisi burada doğuyor — chunk4 kriter 5'in devredilen yarısı, 26.07.2026.)*
18. **Belge bağlantısı üretimi izinden geçiyor:** `SecureFileStore::issueLink()` çağrılmadan önce `document.download` izni ve kullanıcı↔tenant üyeliği doğrulanıyor; izinsiz istek reddediliyor ve bağlantı **üretilmiyor** (test). *(chunk4 §4.4 bağlılık ve erişim logunu kurdu; D-3'ün yetki yarısı burada — chunk4 kriter 8'in devredilen parçası, 26.07.2026.)*

---

## Chunk 7 — Bildirim altyapısı


**Bağımlılık:** chunk1, chunk3, chunk6
**Spec dayanağı:** §10 (10.1–10.6), §12
**Mimari dayanak:** AD-10, AD-17, AD-18, AD-20
**Bitiş şartı:** §7.9 kabul kriterleri.

---

### 7.1 Kanal mimarisi

Tek gönderim noktası: `app/Actions/Notification/SendNotification.php`.

| Kanal | Sağlayıcı | Yer |
|---|---|---|
| E-posta | Postmark | Laravel native transport (AD-17: ek adaptör katmanı gereksiz) |
| SMS | İletiMerkezi | `app/Integrations/IletiMerkezi/` |
| Uygulama içi | — | `notifications` tablosu |
| Anlık | Reverb | chunk8 |
| Discord | Webhook | `app/Integrations/Discord/` |

**Dış servis sözleşmeleri doğrulanmadan yazılmaz.** İletiMerkezi SMS ve İYS API'lerinin endpoint ve alan adları **resmî dokümandan doğrulanacaktır** (CLAUDE.md kural 3). Bu chunk'ta adaptör arayüzleri ve `Null*` implementasyonlar yazılır; gerçek çağrılar doğrulama sonrası bağlanır ve `docs/issues.md`'ye açık kalem yazılır.

Postmark **doğrulanmıştır** (Faz 0'da gerçek gönderim teslim edildi; DKIM/SPF/Return-Path onaylı).

### 7.2 Mesaj sınıfı ve stream ayrımı

AD-17: transactional ve promosyonel **ayrı akışlarda**, karıştırılmaz.

| Sınıf | Stream | Örnek |
|---|---|---|
| `transactional` | `outbound` | Hatırlatma, teklif bildirimi, magic link, süre uyarısı, güvenlik bildirimi |
| `promotional` | `broadcast` | "Yeni eğitim açıldı" duyurusu |

**Zorlama:** her bildirim sınıfı `messageClass(): MessageClass` implement etmek **zorundadır**. Arch testi: `App\Notifications\**` içindeki her sınıf bu metodu taşır. Sınıfı belirsiz bildirim üretilemez.

Stream seçimi bu enum'dan **otomatik**; gönderim çağrısında elle stream verilemez.

### 7.3 İYS kapısı

Spec §10.2. **Promosyonel her gönderim onay kontrolünden geçer — sert kapı.**

`commercial_consents` tablosu: alıcı, kanal (`email` / `sms`), durum (`granted` / `revoked`), tarih, kaynak, kanıt.

| # | Kural |
|---|---|
| I-1 | Mesaj sınıfı `promotional` ise ve alıcının geçerli onayı yoksa gönderim **yapılmaz**; gerekçe loglanır. Uyarı değil, akışın durduğu nokta. |
| I-2 | **Alınan her onay ve her ret İYS'ye bildirilir** (`app/Jobs/ReportConsentToIys.php`). Yükümlülük iki bacaklıdır: içeride kapı, dışarıda kayıt. |
| I-3 | İYS bildirimi başarısızsa **gönderim yapılmaz**; iş yeniden denenir. Kalıcı başarısızlık yöneticiye görünür uyarı üretir. |
| I-4 | Ret (unsubscribe) **derhal** etkili olur ve İYS'ye bildirilir. |
| I-5 | Hizmet/işlem bildirimleri bu kapsamda değildir. |

### 7.4 Teslim edilebilirlik

- Postmark **bounce** ve **spam complaint** webhook'ları `routes/webhooks.php` üzerinden alınır; endpoint imza/secret ile korunur (`POSTMARK_WEBHOOK_SECRET`), **payload doğrulanmadan işlenmez** (AD-20 güvenlik sınırı 2).
- İlgili adres `suppression_list` tablosuna eklenir; bu listedeki adrese **tekrar gönderim denenmez** — kontrol `SendNotification` içinde.
- Webhook URL'leri deploy sonrası bağlanır (EGT-47); bu chunk'ta endpoint ve doğrulama hazır olur.
- ⚠ Webhook rotaları `TenantContext` gerektirmez ve `EnsureTenantContext` middleware'inden **muaftır**; gelen veri tenant'ı payload'dan çözer ve işlemi `TenantContext::run()` içine alır.

### 7.5 Şablonlar ve çeviri

- Şablonlar **uygulama içinde**, veritabanında ve **sürümlü** (AD-17 — Postmark template'leri kullanılmaz: tek kaynak, audit'lenebilir).
- `notification_templates`: `key`, `channel`, `version`, `subject`, `body`, `is_active`. `subject` ve `body` **çevrilebilir `jsonb`** (chunk1 §1.4).
- Gönderilen her bildirimde **hangi şablon sürümü ve hangi dil** kullanıldığı `notification_deliveries` tablosuna yazılır.
- Alıcının dili chunk1 §1.4 zinciriyle çözülür.
- Şablon düzenleme backoffice'ten; `Auditable`.
- **Eksik çeviri raporu:** backoffice'te hangi şablonun hangi dilde eksik olduğu listelenir.

### 7.6 Tercihler ve kritik bildirimler

- `notification_preferences`: kullanıcı × bildirim türü × kanal.
- **Kritik bildirimler kapatılamaz** (spec §10.4): süre dolumu, bakanlık bildirimi uyarısı, onay talebi, güvenlik bildirimleri.
- Kritiklik bildirim sınıfının kendisinde tanımlıdır (`isCritical(): bool`), tercih tablosunda değil — kullanıcı kapatmayı deneyemez bile.

### 7.7 Kademeli süre bildirimleri

Spec §10.5: 90/60/45/30/15/7 gün. Kademeler `config('notifications.reminder_days')` — admin-tanımlı (AD-08).

- `app/Jobs/ScanExpiryReminders.php` — günlük schedule. Geçerlilik süresi olan kayıtları tarar, eşiğe girenlere bildirim üretir.
- Bu chunk **motoru** kurar; hangi kayıt türlerinin taranacağı dikey planlarda `ExpiryReminderSource` arayüzünü implement ederek kaydedilir.
- Aynı kayıt + aynı kademe için **tekrar gönderilmez** (`expiry_reminders` tablosunda dedup).

⚠ **P-8'in çözümü — takvim varsayımı.** Spec §12 süre hesaplarının tanımlı takvime dayanmasını ister. ⛔ **Resmî tatil takvimi hiç olmayacak** *(Sets, 18.08.2026 — EGT-43 Won't fix: resmî böyle bir liste yok, sektör 7/24/365 çalışıyor, tatil gününde iş akışını firmalar kendileri ayarlıyor)*. Yani aşağıdaki varsayım artık varsayım değil, **kalıcı karardır** — ve yalnız hatırlatmalar için değil, onay eskalasyonu ve eğitmen süreleri dâhil her yerde geçerlidir.

> **Bu chunk'ta kademeli hatırlatmalar TAKVİM GÜNÜ üzerinden hesaplanır** — iş günü değil. Gerekçe: hatırlatma erken gitmekten zarar görmez; iş günü hesabı yalnız **onay eskalasyonu** için kritiktir ve o Spec 3'ün konusudur. Takvim geldiğinde iş günü hesabı gerektiren yerler ona bağlanır; hatırlatmalar takvim günü kalabilir. Bu varsayım `config('notifications.reminder_basis') = 'calendar_days'` olarak **açıkça** yazılır ki değiştirilmesi gerektiğinde tek yer olsun.

### 7.8 Discord — uygulama içi iş olayları

Kanal eşlemesi `config('notifications.discord')`:

| Olay | Kanal |
|---|---|
| Ofis kullanıcı girişi, yeni ofis kullanıcısı | `prod-office` |
| Yeni müşteri, müşteri kullanıcısı girişi | `prod-customer` |
| Gönderilen teklif, kesilen fatura | `prod-accounting` |

⚠ `debug` ve `prod-debug` kanalları **uygulamanın işi değildir** — Sentry → outage-bot hattına aittir (Faz 0'da kuruldu). Uygulama bu iki kanala **yazmaz**; arch testiyle denetlenir.

**Gürültü kontrolü** (Sets kararı): normal girişler anlık gönderilmez. Anlık gidenler: **yeni cihaz / yeni IP / başarısız deneme**. Normal girişler `app/Jobs/SendDailyLoginDigest.php` ile **günlük özet**.

**PII:** iç kanal olduğu için Discord mesajlarında maskeleme **uygulanmaz** (Sets kararı). Bu, chunk3'teki audit maskelemesinden ayrıdır ve bilinçlidir.

### 7.9 Kabul kriterleri

1. Her bildirim sınıfı `messageClass()` döndürüyor; döndürmeyen arch testini kırıyor.
2. Stream seçimi enum'dan otomatik; gönderim çağrısında elle stream verilemiyor (imza kontrolü).
3. Onayı olmayan alıcıya promosyonel gönderim **yapılmıyor** (test).
4. İYS bildirimi başarısızken promosyonel gönderim yapılmıyor; iş yeniden deneniyor (test).
5. Ret derhal etkili; sonraki promosyonel gönderim engelleniyor (test).
6. Bounce/spam webhook'u imza doğrulaması olmadan işlenmiyor (test).
7. Suppression listesindeki adrese gönderim denenmiyor (test).
8. Webhook rotaları tenant context middleware'inden muaf; işlem `TenantContext::run()` içinde yürüyor (test).
9. Şablonlar veritabanında, sürümlü ve çevrilebilir; gönderimde kullanılan **sürüm ve dil** kaydediliyor (test).
10. Eksik çeviri raporu doğru şablonları listeliyor (test).
11. Kritik bildirim kullanıcı tercihiyle kapatılamıyor (test).
12. Kademeli uyarı aynı kayıt + kademe için ikinci kez gönderilmiyor (test).
13. `reminder_basis` config'te açıkça `calendar_days`; kademe hesabı bu değere göre yapılıyor (test).
14. Discord kanal eşlemesi doğru; uygulama `debug`/`prod-debug` kanallarına yazmıyor (arch testi).
15. Normal girişler anlık Discord mesajı üretmiyor; günlük özet üretiyor (test).
16. **chunk5 §5.2 U-5 tamamlanıyor:** yetki değişikliğinde kişiye bildirim gidiyor (test).
17. **chunk5 A-3/A-4 tamamlanıyor:** yeni cihaz girişi ve hesap güvenliği değişiklikleri bildirim üretiyor (test).
18. İletiMerkezi adaptörü arayüz + null implementasyon; gerçek API `docs/issues.md`'de açık kalem.

---

## Chunk 8 — Realtime ve kullanım analitiği


**Bağımlılık:** chunk3, chunk5, chunk6
**Spec dayanağı:** §11 (RT-1…RT-6), §8.5
**Mimari dayanak:** AD-21, AD-22
**Bitiş şartı:** §8.9 kabul kriterleri.

> **P-9:** chunk3'e bağımlılık burada açıkça yazılıdır — analitik ekranları chunk3 §3.9'daki continuous aggregate'i tüketir.

---

### 8.1 Reverb kurulumu

`laravel/reverb` v1.11.0. Faz 0'da altyapı hazırlandı: `ws.infra.egitim.dev.sets.fyi` → `127.0.0.1:8080`, nginx WS proxy kurulu ve doğrulandı.

- Süreç **supervisor** altında (docker'a konmaz — PHP process'idir).
- `BROADCAST_CONNECTION=reverb`; testte `null` (chunk1 §1.6).
- Prod'da Reverb host üzerinde koşar; `network_mode: host` gerekmez.

### 8.2 Kanal modeli (RT-2)

**Yalnız kimliğe özel private kanal.** Presence channel **kullanılmaz** (AD-22).

Kimlik alanı başına ayrı kanal ailesi (spec §3 — üç alan; backoffice kanalı bildirim ihtiyacı doğduğunda eklenir, bugün ofis ve müşteri tarafı kurulur):

```
private-office.user.{id}
private-customer.user.{id}
```

- Kanal yetkilendirmesi `routes/channels.php`: yalnız kullanıcının kendisi, kendi kimlik alanında.
- Başka kanal tanımlanmaz: tenant kanalı, eğitim kanalı, rol kanalı **yok**. Bir kanal birden fazla kullanıcıya yayın yaparsa izolasyon kanal yetkilendirmesine bağımlı hale gelir; kullanıcı bazlı kanalda böyle bir risk yoktur.

#### 8.2.0 Geçerli oturum yoksa hiçbir veri çıkmaz (spec RT-7)

**Sets kararı, 27.07.2026.** Anlık katman kendi kimlik doğrulama yolunu açmaz. Abonelik ve bağlantı kimliklendirmesi HTTP tarafındaki **aynı** oturumdan geçer; oturum yoksa hiçbir kanal veri vermez.

**Doğrulanmış olgular** (framework kaynağından, 27.07.2026):

- `Broadcast::routes()` ve `Broadcast::userRoutes()` iki uç kaydeder — `/broadcasting/auth` (kanal aboneliği) ve `/broadcasting/user-auth` (Pusher signin) — ve **varsayılan olarak `['middleware' => ['web']]`** ile kaydederler.
- İkisi de **CSRF'ten muaftır** (`withoutMiddleware(PreventRequestForgery::class)`). Yani bu iki ucu yetkilendiren tek şey **oturum çerezidir**; `SameSite` ayarı burada güvenlik parametresidir, konfor değil.
- Kanal yetkilendirmesi kullanıcıyı `retrieveUser()` ile çözer: kanal `guards` bildirmemişse **`$request->user()`** çağırır — yani **varsayılan guard**, ki bu projede **yoktur** (chunk5 §5.1.1).

**Bu chunk'ın kuralları** (`CH-` öneki: `W-1…W-6` chunk2 §2.6.1'in SECURITY DEFINER yazma sözleşmesine aittir ve çakışma downstream atıflarını belirsiz bırakırdı — heyet 141 F-4'ün aynı dersi):

| # | Kural |
|---|---|
| CH-1 | Her iki broadcasting ucu da uygulamanın **`web` grubundan** geçer. Grup `ResolveIdentityDomain`, `StartSession` ve `EnsureAccountIsActive` taşır; dolayısıyla "oturum yok / oturum düşmüş / hesap pasif" üç durumu da **HTTP katmanında** reddedilir ve anlık katmanın ayrıca kontrol etmesine gerek kalmaz. `Broadcast::routes()` özel `middleware` dizisiyle çağrılmaz — çağrılırsa bu kural sessizce delinir |
| CH-2 | Her kanal tanımı **guard'ını açıkça bildirir** (`Broadcast::channel(..., ['guards' => ['office']])`). `$request->user()`'a düşmek, varsayılan guard'ın olmadığı bir projede "hangi kimlik alanı" sorusunu bizim `resolveUsersUsing` seam'imize bırakır; kanal yetkilendirmesi bu kadar dolaylı bir cevaba yaslanmamalıdır |
| CH-3 | Kanal adı **kimlik alanını taşır** (`private-office.user.{id}`) ve yetkilendirme hem id'yi hem alanı doğrular. Üç alanın id'leri çakışır; yalnız id karşılaştıran bir kontrol, ofis kullanıcısını aynı numaralı müşteri kullanıcısının kanalına sokar |
| CH-4 | **Public kanal yoktur.** Yetkilendirmeden geçmeyen bir kanal, tanımı gereği "oturumsuz veri" demektir |
| CH-5 | Bağlantı kurulduktan sonra oturumun düşmesi bağlantıyı **yaşatmaz** — §8.2.1'in sonlandırması bu maddenin devamıdır. Abonelik anındaki kontrol tek başına yeterli değildir; kanal ömrü boyunca açık kalır |

**Kabul kriteri (bu chunk):** oturumsuz bir istemci `/broadcasting/auth`'tan 401/403 alır ve hiçbir kanala abone olamaz; oturumu olan ama **pasifleştirilmiş** bir hesap da aynı şekilde reddedilir (üç kimlik alanında da ayrı test).

#### 8.2.1 Donan hesabın bağlantısı da kapanır (spec A-5a)

Pasifleştirme **anında** etki eder ve etkisi HTTP oturumuyla sınırlı değildir: kişinin açık **WebSocket bağlantısı** da o anda kapatılmalıdır. Gerekçe tam da anlık bağlantının doğasıdır — kilitlenen kişi ekranda oturuyor, istek atmıyor, ama kanal ona veri akıtmaya devam ediyor; hiçbir HTTP middleware'i devreye girmiyor.

chunk5 HTTP tarafını kapattı (durum değişikliği o kimliğin bütün oturum satırlarını siler, `TerminatesSessions`). **Bu chunk'ın borcu bağlantı tarafıdır.**

**Doğrulanmış yol** (paket kaynağından, 27.07.2026):

- Reverb 1.11 `POST /apps/{appId}/users/{userId}/terminate_connections` ucunu taşır (`UsersTerminateController`); kurulu `pusher/pusher-php-server` tarafında karşılığı `terminateUserConnections(string $userId)`'dir.
- ⚠ **Tuzak — sessizce hiçbir şey yapmaz:** controller eşleşmeyi `$connection->data()['user_id']` üzerinden yapar ve o alan **yalnız Pusher'ın signin (user authentication) akışıyla** dolar. Echo tarafında `userAuthentication` kurulmazsa bağlantılar kullanıcı kimliği taşımaz, `terminate_connections` hiçbir bağlantıyla eşleşmez ve çağrı **başarılı döner**. Bu, "yazıldı ama tutulmuyor" sınıfının en sinsi hâlidir: kod var, çağrı var, sonuç yok.
- Kullanıcı kimliği **kimlik alanıyla birlikte** taşınmalıdır (`office:12` gibi): üç alanın id'leri çakışır, ham id ile sonlandırma yanlış kişinin bağlantısını kapatır (chunk5 §5.1.1 ile aynı çakışma).

**Kabul kriteri (bu chunk):** iki farklı cihazdan bağlı bir kullanıcı pasifleştirildiğinde her iki WebSocket bağlantısı da kapanır; ve `userAuthentication` yapılandırması olmadan sonlandırmanın **eşleşmediğini** gösteren negatif kontrol yazılır — çağrının sessizce başarılı dönmesi kanıt sayılmaz.

### 8.3 Taşınan içerik (RT-5)

**Anlık kanaldan hassas veri içeriği taşınmaz** — olay bildirimi taşınır.

Yayınlanan yük yalnız: olay türü, ilgili kaydın referans kodu veya id'si, kısa başlık, zaman. İçerik gerektiğinde **yetkili sorguyla** alınır.

**Zorlama (arch testi):** `ShouldBroadcast` implement eden hiçbir sınıf public Eloquent model property tutamaz; `broadcastWith()` yalnız beyaz listedeki alanları döndürür.

### 8.4 "Çalışıyor" göstergesi (RT-3)

Spec: *"Bu kayıt üzerinde başka bir kullanıcı çalışıyor"* — kim olduğu ve hangi müşteri için çalıştığı **gösterilmez**; tavsiye niteliğinde, bloklamaz, kendiliğinden sönümlenir.

- Redis TTL'li anahtar: `working:{record_type}:{record_id}:{identity_type}:{identity_id}`, TTL 90 sn, ekran açıkken 60 sn'de bir yenilenir.
- Ekranda gösterilen **yalnız sayı**: "1 kullanıcı daha bu kayıt üzerinde çalışıyor". Kullanıcı kimliği, tenant bilgisi veya müşteri adı **yayınlanmaz**.
- Sayı hesaplanırken kullanıcının kendi anahtarı düşülür.
- TTL dolunca kayıt kendiliğinden düşer — orphan lock kalmaz, elle temizleme işi yoktur.
- Hiçbir akışı bloklamaz.

### 8.5 Çevrimiçi göstergesi ve son aktif (RT-6, §8.5)

| Ne | Kim görür | Kaynak |
|---|---|---|
| **Çevrimiçi rozeti** | Yalnız `analytics.view` izni olan ofis kullanıcıları (direktör + operasyon direktörü) | Son aktivite < 5 dk |
| **Son aktif** | Kullanıcı kartında — hem `users` hem `customer_users` | `last_active_at` |

- ⚠ **`customer_users` için "son aktif", spec §7.5 L-1'in adlandırılmış tek istisnasıdır (L-1a).** Ofiste görünen **yalnız damganın kendisidir**; giriş geçmişi, oturum listesi, giriş sayısı veya geçirilen süre ofis tarafında hiçbir ekranda gösterilmez. İstisnaya başka türetilmiş alan eklenemez — eklemek spec değişikliğidir.
- Müşteri ve katılımcı arayüzlerinde çevrimiçi göstergesi **hiç render edilmez**.
- `last_active_at` her istekte değil, **5 dakikada bir** güncellenir (yazma yükü).
- **Toplam çevrimiçi süre, oturum süresi toplamı, aktiflik skoru üretilmez** — chunk9 §9.5 taraması denetler.

### 8.6 Analitik ekranları

Veri toplama chunk3 §3.9'da kuruldu (`analytics_usage_daily`, `user_id` içermez).

- `app/Queries/Analytics/UsageSummary.php` — kırılım: müşteri tarafında `tenant × role × route`, ofis tarafında `role × route`.
- **k-anonimlik eşiği 2:** `person_count < 2` olan satır **döndürülmez**, bir üst kırılıma yuvarlanır. Zorlama **sorgu katmanında**; ekran filtresinde değil (ekran filtresi atlanabilir, sorgu katmanı atlanamaz).
- **Kişi kırılımı parametresi yok:** sorgu sınıfının imzası böyle bir parametre kabul etmez. Kötüye kullanım teknik olarak mümkün değildir.
- Ekran yalnız backoffice'te (Filament), `analytics.view` izniyle.

### 8.7 Dayanıklılık (RT-4)

Anlık bağlantı koparsa uygulama çalışmaya devam eder.

- **Sıralama:** önce `notifications` tablosuna yazılır, **sonra** broadcast edilir. Tersi olursa broadcast gider ama kayıt olmayabilir.
- Broadcast başarısızlığı iş akışını **durdurmaz** (spec §12); bildirim zaten uygulama içi tabloda.
- Frontend: Echo bağlantısı yoksa hata gösterilmez; bildirimler bildirim merkezinden görülür.

### 8.8 Frontend bağlantısı

- Echo yapılandırması `resources/js/echo.js`; kimlik alanına göre doğru kanala abone olur.
- Bildirim merkezi bileşeni (`resources/js/organisms/NotificationCenter.vue`) — atomic design (AD-25).
- Kullanıcı metinleri `lang/` üzerinden (chunk1 §1.4).

### 8.9 Kabul kriterleri

1. Reverb supervisor altında çalışıyor; `ws.infra.*` host'u WS handshake dönüyor (502 değil).
2. Yalnız iki kanal ailesi tanımlı (`private-office.user.{id}`, `private-customer.user.{id}`); başka kanal yok.
3. Kullanıcı başka kullanıcının kanalına abone olamıyor; ofis kimliği müşteri kanalına abone olamıyor (iki test).
3a. **Oturumsuz istemci hiçbir veri alamıyor** (spec RT-7): `/broadcasting/auth` oturumsuz istekte reddediyor, pasifleştirilmiş hesabın oturumuyla da reddediyor; üç kimlik alanında ayrı ayrı sınanıyor. Her iki broadcasting ucunun da uygulamanın `web` grubundan geçtiği rota kaydıyla doğrulanıyor (CH-1).
3b. **Pasifleştirilen hesabın açık WebSocket bağlantısı kapanıyor** (spec A-5a, §8.2.1): iki cihazdan bağlı kullanıcı donduğunda iki bağlantı da düşüyor. ⚠ Negatif kontrol zorunlu: `userAuthentication` yapılandırılmadığında sonlandırmanın hiçbir bağlantıyla **eşleşmediği** gösteriliyor — çağrının sessizce başarılı dönmesi kanıt sayılmıyor.
4. Broadcast yükünde Eloquent model taşınmıyor; yalnız beyaz listedeki alanlar (arch testi, kasıtlı ihlalle doğrulanmış).
5. "Çalışıyor" göstergesi yalnız **sayı** döndürüyor; kullanıcı kimliği veya müşteri bilgisi yayınlanmıyor (test).
6. TTL dolduğunda gösterge kendiliğinden düşüyor (test).
7. Gösterge hiçbir akışı bloklamıyor (test).
8. Çevrimiçi rozeti müşteri ve katılımcı arayüzlerinde **hiç render edilmiyor** (test).
9. `last_active_at` 5 dakikada bir güncelleniyor, her istekte değil (test).
9b. Müşteri kullanıcısı için ofiste görünen **yalnız `last_active_at` damgası**; giriş geçmişi, oturum listesi, giriş sayısı veya geçirilen süre gösteren bir alan/uç **yok** (test + **chunk9 S-15** taraması — spec §7.5 L-1a'nın dar tutulması).
10. Analitik sorgusu `person_count < 2` satırı döndürmüyor (test).
11. Analitik sorgu sınıfı kişi kırılımı parametresi **kabul etmiyor** (imza kontrolü).
12. Analitik ekranı yalnız `analytics.view` izniyle erişilebilir (test).
13. Bildirim önce tabloya yazılıyor, sonra broadcast ediliyor (sıra testi).
14. Reverb kapalıyken bildirim üreten akış çalışmaya devam ediyor ve bildirim merkezinde görünüyor (test).
15. Frontend metinleri `lang/` üzerinden geliyor; koda gömülü metin yok (arch testi).
16. **`BackofficeAuditFeed` production yolundan çağrılıyor** — backoffice denetim ekranı sınıfı kullanıyor, reverse-grep caller sayısı > 0. *(chunk3'te orphan olarak bilinçli kabul edilmişti — Sets kararı, 26.07.2026, `docs/issues.md`. Kabul erteleme olduğu için kapanışı bu kritere bağlıdır.)*

---

## Chunk 9 — Test altyapısı, kural taraması, rollout, rollback


**Bağımlılık:** tüm chunk'lar
**Spec dayanağı:** §14 (30 kabul kriteri), §12
**Mimari dayanak:** AD-26
**Bitiş şartı:** §9.8 kabul kriterleri.

---

### 9.1 İki suite ve test rolleri

| Suite | Grup | TestCase | Veritabanı | Paralel |
|---|---|---|---|---|
| Normal | `omurga` + alan grupları | `Tests\TestCase` | `RefreshDatabase` | ✅ |
| RLS | `omurga-rls` | `Tests\RlsTestCase` | Truncation, transaction sarmalaması yok | ❌ |

Test bağlantısı **`3eegitim_test`** rolüyle (non-superuser). `RlsTestCase::setUp()` ilk iş:

```
assertFalse(DB::selectOne("SELECT rolsuper FROM pg_roles WHERE rolname = current_user")->rolsuper)
```

Bu doğrulama **her RLS testinde** koşar. Superuser rolle koşulan RLS testi her zaman geçer ve yanlış güven verir (chunk2 deney 1).

### 9.2 Spec kriterlerinin test eşlemesi

Spec §14'ün **30 kabul kriteri** test senaryosuna birebir eşlenir. Test adı kriter numarasını taşır: `omurga_criterion_07_...`.

⚠ **Eşleme ayrı bir dosyada biriktirilmez** (düzeltme 27.07.2026): planın **kriter sicili** bu işi gün gün yapar — her spec kriteri için mekanizmayı kuran bölüm, kanıtlayan bölüm ve güncel durum orada tutulur. chunk9'un işi o sicili **test adlarıyla tamamlamak** ve `açık` satır kalmadığını doğrulamaktır; sıfırdan bir eşleme çıkarmak değil. Sonda toplanan bir eşleme, tam da bu planın öğrendiği hatanın kendisiydi: kayma anında kaydedilmezse sonra arkeoloji olur.

Ek zorunlu kapsamlar (AD-26):

| Kapsam | Nerede |
|---|---|
| Her tenant-kapsamlı tablo için negatif izolasyon testi | chunk2 R-1, R-2 |
| Her policy için yetkisiz rol testi | chunk6 |
| Durum makinesi, para/hesap, mevzuat kuralları | Omurgada yok — dikey planlarda |

**Test edilmeyecekler:** framework davranışı, Filament'in kendi render'ı, getter/setter.

### 9.3 Flaky önleme denetimi

chunk1 §1.6'da kuruldu; burada denetlenir:

| Kontrol | Kanıt |
|---|---|
| `Carbon::setTestNow()` global | Arch testi çıplak `now()` yakalıyor |
| `Http::preventStrayRequests()` | Dış çağrı yapan test **kasıtlı denemeyle** kırıldığı gösterilir |
| `CACHE_STORE`/`SESSION_DRIVER`/`BROADCAST_CONNECTION`/`MAIL_MAILER` | `phpunit.xml` `force="true"` |
| `Storage::fake()` | Dosya testlerinde zorunlu |
| Redis izolasyonu | Test Redis DB numarası ayrı; parallel'de process başına ayrı numara |
| Sıra bağımlılığı | **Nightly `--order-by=random` tam koşum** |

### 9.4 CI kapıları

| Aşama | Komut | Kural |
|---|---|---|
| Geliştirme | `composer test:omurga` | Scoped, hızlı |
| RLS değişikliği | `composer test:rls` | Paralel değil |
| Commit öncesi | `pint --dirty` + `phpstan` + ilgili grup | |
| **Merge öncesi** | `teamcity.sh trigger` → `wait` | **Tam koşum ZORUNLU** |
| Nightly | Rastgele sıralı tam koşum | Sıra bağımlılığı avı |

**Retry ile geçirme yasak.** Tutarsız test:

1. `karantina` grubuna alınır (koşumdan çıkarılmaz, ayrı raporlanır).
2. **Otomatik OPS ticket'ı açılır** (outage-bot'un duplicate mantığı, Faz 0'da kuruldu).
3. Karantinada 5'ten fazla test birikirse **yeni feature yasak**.

⏳ **Mekanizma YAZILMADI ve bu bilinçli** *(Sets, 07.08.2026)*. Bugün tutarsız test yok; şimdi kurulacak olan, hiç görülmemiş bir sorun için yazılmış boş bir iskele olurdu — ve iskelenin şeklini ancak ilk gerçek vaka söyleyebilir: hangi grup düşüyor, hangi sıklıkla, ticket'a yazılacak olan ne. **Kapanış adresi:** ilk tutarsız test çıktığında bu üç madde o testle birlikte kurulur; kurulmadan önce testin retry ile geçirilmesi yine yasaktır (AD-26).

⚠ **Bugüne kadar tek "tutarsızlık" kaynağı ölçüldü ve test değildi:** `omurga-rls` grubu, aynı veritabanına paralel başka bir koşum varken toplu düşüyor (migration deadlock'u, ardından "relation does not exist" zinciri). Tek başına koşturulduğunda 333/333 geçiyor. Bu bir karantina vakası değil, bir koşum kuralı — grup yalnız başına koşar.

**Hız bütçesi:** grup başına < 60 sn, tam koşum < 5 dk. Aşıldığında otomatik "test hızlandırma" ticket'ı.

✅ **Karşılandı, 07.08.2026.** Ölçüm: omurga 37 sn · backoffice 39 sn · **omurga-rls 43 sn** · mevzuat 29 sn · planlama 17 sn · geri kalan hepsi 10 sn altında; toplam ~3,5 dk.

⚠ `omurga-rls` 66 saniyeden 43'e **tek bir değişiklikle** indi ve emilen şey testler değildi. `DatabaseTruncation` tabloları tek tek dolaşıp her biri için önce `SELECT exists(...)` sorup sonra kendi `TRUNCATE`'ini atıyor — 176 tablo, tek satır silinmeden önce 176 gidiş-dönüş, ve bu **her testten önce**. Bütün tabloların tek seferde truncate'i **85 milisaniye** sürüyor; maliyet silmek değil, 333 kez bunun muhabbetini yapmaktı. `Tests\Concerns\TruncatesDatabase` artık aynı tabloları iki gidiş-dönüşte boşaltıyor — hangi tabloların seçildiğini yine framework'ün kendi `tablesToTruncate`/`exceptTables` kancaları söylüyor, kimlik sayaçları yine sıfırlanıyor. **Ne boşaltıldığı değişmedi**; yalnız kaç cümlede söylendiği değişti (Sets'in şartı: orada hız değil doğruluk önemli).

⚠ Grubun en yavaş **tek** testi hâlâ 7,6 sn: `it never issues one code twice`, 10.000 kod üretip çakışma olmadığını gösteriyor. Bu bir savurganlık değil, kriterin kendisi — 28⁴ olasılık içinde 10.000 kod, çakışmanın **beklendiği** bir boy. Küçültmek testi zayıflatmak olurdu ve bütçe zaten karşılandığı için gereği yok.

### 9.5 Kural taraması

Bazı kurallar teste değil taramaya konu olur.

⛔ **`rule-scan.sh` YAZILMADI; on dokuz kural arch testi olarak koşuyor** *(Sets, 07.08.2026)*. Suite zaten CI'da koşuyor; ikinci bir tarayıcı aynı allowlist'lerin ikinci kopyası olurdu ve bayatlayan kopya, kimsenin bakmadığı kopya olurdu. Planın istediği şart — **bulgu build'i kırar** — düşen bir testle zaten karşılanıyor.

**Nerede:** on üçü `tests/Arch/RuleScanTest.php`; altısı daha önce yazılmıştı — S-1/S-7 `SensitiveDataTest`, S-4 `TenancyTest`, S-5 `EnvTest`, S-8 `NotificationChannelTest`, S-10 `LocaleTest`.

⚠ **Taramalar dosyayı yorumları silerek okur.** Ölçüldü: audit tablosu adı geçen otuz dosyanın on dokuzu adı *"burayı okumuyoruz çünkü…"* cümlesinde anıyordu. Açıklamayı cezalandıran bir tarama, açıklamasız kopyayı geçirir ve bir hafta içinde kapatılır.

⚠ **S-12/S-13 dizge değil, SORGU BAŞLATMA arar** *(Sets kararı, 07.08.2026)*. Altı ekran `App\Queries\Audit`'in verdiği satırları `AuditWrite $row` diye tipliyor ve bir tip ipucu hiçbir veritabanına ulaşmıyor. Aşağıdaki tariflerin harfi bu altısını da kırardı — ve insanlara sorguyu değil tipi silmeyi öğretirdi. Yasak olan: `AuditWrite::query()`, `DB::table('audit_writes')`, `new AuditWrite` ve eşlenikleri.

⚠ **S-17 `original_filename`'i kapsamaz** *(Sets kararı, 07.08.2026)*. `storage_path` bir **adres**, `original_filename` ekranda yazan bir **etiket**; adres erişim verir, etiket vermez. `storage_path` `app/Http` ve `routes/` altından tamamen çıkarıldı (üç uç nokta artık kaydı `Support\Files\HouseFile`'a veriyor); dosya adı iki payload'da ekran etiketi olarak kalır.

⚠ **Her kural kasıtlı ihlalle doğrulandı.** S-6 ilk yazımında hiçbir şey yakalamıyordu: desen `token` kelimesinden hemen sonra `=>` bekliyordu, oysa gerçek kod `'token' => '…'` yazar ve tırnak arada durur. Gerçekçi bir sahte anahtar ekilip taramanın geçtiği görülmeseydi kural sessizce boş kalırdı.

| # | Kural | Tarama |
|---|---|---|
| S-1 | Otomatik veri silme yok (spec v1.3) | Saklama süresine bağlı silme/anonimleştirme yapan schedule veya job yok |
| S-2 | Kişi bazlı kullanım metriği yok (§8.5) | Toplam çevrimiçi süre / aktiflik skoru üreten kod yok |
| S-3 | Çapraz tenant eşleme yok (§3.2) | TCKN/telefon/e-posta üzerinden tenant bağımsız sorgu yok. ⚠ **`email_hash` özel durumu** (spec §8.1, chunk5 §5.1): bu kolon tenant'tan bağımsız olduğu için tanımı gereği çapraz-tenant karşılaştırılabilir, dolayısıyla yasağı ada göre değil **çağırana** göre işler. Dizge **yalnız şu allowlist'te** geçebilir: `app/Models/CustomerUser.php` (kolonu değerle güncel tutan tek yer) · `database/migrations/**` · `tests/**`. ⚠ `app/` altında bugün **yalnız bir dosyada** geçiyor: kapı kolonu adıyla değil `app.authenticate_lookup()` fonksiyonu üzerinden okur, hash'i üreten `TenantKeyDeriver::lookupHash()` ise kolon adını hiç bilmez. Bu darlık kazara değil — sorgu yüzeyi tek fonksiyona kapalı olduğu için allowlist bu kadar kısa olabiliyor. Başka bir dosyada geçmesi build'i kırar — kabul edilmiş artık risk ancak tek yoldan kullanıldığı sürece kabul edilebilir kalır |
| S-4 | `SET` (LOCAL'siz) yok — **`SET app.*` ailesinin tamamı** | chunk2 §2.5. Tek değişken adına bağlı değildir: `SET app.tenant_ids`, `SET app.audit_read_all` ve sonradan eklenen her `app.` oturum değişkeni; `set_config('app.…', …, false)` biçimi de aranır |
| S-5 | `env()` config dışında yok | chunk1 arch testi ile örtüşür, tarama ikinci kapı |
| S-6 | Hardcoded credential yok | Bilinen desenler (token, password, secret, webhook URL) |
| S-7 | Üçüncü taraf istemci analitiği yok (§8.4 K-3) | GA/Mixpanel benzeri script yok |
| S-8 | Uygulama `debug`/`prod-debug` Discord kanallarına yazmıyor | chunk7 §7.8 |
| S-9 | **Kapsam dışı `SECURITY DEFINER` fonksiyonların envanteri** | chunk2 §2.6 G-5: tüm `SECURITY DEFINER` fonksiyonlar listelenir ve beklenen listeyle karşılaştırılır; beklenmeyen fonksiyon build'i kırar. ⚠ **Karşılaştırma AD değil İMZA bazlıdır** — `app` şemasındaki fonksiyonların `oid::regprocedure` çıktısı beklenen imza listesiyle birebir eşleşir. Gerekçe olgusal (chunk5 E-2 dersi): `CREATE OR REPLACE FUNCTION` yalnız aynı argüman tipli fonksiyonu değiştirir, imza değişince eski ve **daha geniş** olan yanında **çağrılabilir hâlde** kalır; `migrate:fresh` de fonksiyonları düşürmez. Ad bazlı bir envanter bu durumu "beklenen fonksiyon mevcut" diye okur ve hiçbir şey söylemez |
| S-10 | Koda gömülü kullanıcı metni yok | chunk1 arch testi ile örtüşür |
| S-11 | **Çapraz-tenant audit okuma bayrağı dar kalıyor** (chunk3 §3.6.1 B-2/B-3) | `app.audit_read_all` dizgesi **yalnız iki dosyada** geçer: `app/Support/Audit/AuditReadScope.php` (bayrağı KURAN) ve `app/Support/Database/AuditPolicy.php` (bayrağı OKUYAN politikayı yazan). ⚠ **Düzeltme, Sets 07.08.2026:** bu satır ikinci dosya olarak chunk3'ün migration'ını yazıyordu; politika SQL'i o migration'dan çıkıp `AuditPolicy` sınıfına taşındı ve migration artık sınıfı çağırıyor. Kuralın şekli değişmedi — bir kuran, bir politika — yalnız politikanın adresi değişti. Aynı turda sınıfın çağıranı olmayan iki `public` metodu (`evidenceStatements`, `usageStatements`) `private` yapıldı: bayrağı iki dosyaya kapatmanın değeri, o dosyalara açılan kapı sayısı kadardır. ⚠ **`app.audit_read_all()` fonksiyonunun `CREATE FUNCTION`'ı chunk3'ün migration'ındadır** — chunk2'nin context-functions migration'ına konmaz; `app.tenant_ids_or_empty()` de aynı şekilde chunk3'e aittir (ikisi de bu chunk'ın ürünüdür). Üçüncü bir dosyada geçmesi build'i kırar |
| S-12 | **Audit tabloları yalnız adlandırılmış bileşenlerden sorgulanıyor** (chunk3 §3.6 N-4) | `audit_writes` / `audit_access` tablo adı veya `AuditWrite`/`AuditAccess` modeli **yalnız şu allowlist'te** geçer: `app/Queries/Audit/**` (okuma) · `app/Support/Audit/**` (yazma ve `AuditReadScope`) · `app/Models/AuditWrite.php`, `app/Models/AuditAccess.php` (bağlama) · `app/Jobs/SealAuditBatch.php`, `app/Jobs/PublishAuditRoots.php`, `app/Jobs/ArchiveAuditBatch.php`, `app/Jobs/VerifyAuditChain.php` (mühür/yayım/arşiv/doğrulama) · `config/audit.php` (tablo haritası ve owen-it `implementation` bağlaması — chunk3 §3.1/§3.4) · `database/migrations/**` · `tests/**`. Listede olmayan dosyada geçmesi build'i kırar; listeyi büyütmek **plan değişikliğidir**. N-4'ün aktör filtresi tek katman olduğu için sınıfın atlanabilirliği burada kapanır |
| S-13 | **İş akışı ekranları audit'ten beslenmiyor** (chunk3 §3.1) | Taranan yollar: `app/Filament/**` · `app/Http/Controllers/**` · `app/Http/Resources/**` · `resources/js/**`. Bu yollarda `audit_writes` / `audit_access` / `audit_usage` tablo adı veya `AuditWrite` / `AuditAccess` / `AuditUsage` modeli **doğrudan** geçmez; audit verisi ekrana yalnız `App\Queries\Audit\**` sınıfları üzerinden taşınır. Denetim ekranları bu yüzden geçer, teklif/eğitim ekranları geçemez — ayrım "niyet" değil, **hangi namespace'ten beslendiği** |
| S-14 | **Kullanım logunun kullanıcıya dönük ekranı yok** (chunk3 kriter 18) | (a) `audit_usage` dizgesi S-13'ün taradığı dört yolda ve `routes/**` altında geçmez — allowlist: `app/Support/Audit/UsageLogger.php`, `config/audit.php`, `database/migrations/**`, `tests/**`. (b) `audit_activity` dizgesi `app/`, `config/`, `database/`, `tests/` altında hiç geçmez |
| S-15 | **L-1a dar kalıyor** (spec §7.5, chunk8 §8.5) | Müşteri kullanıcısı için ofise dönen yüzeyde `last_active_at` dışında oturum/giriş türevi alan yok. **Sonlu dizge listesi:** `login_count` · `last_login_at` · `login_at` · `session_duration` · `online_seconds` · `total_online` · `activity_score`. Bu yedi dizge `app/` ve `database/migrations/**` altında geçmez. Listeye ekleme **plan değişikliğidir**; listede olmayan bir varyantı yakalamak taramanın değil review'ın işidir |
| S-16 | **Şifreli kolona yalnız model attribute yolundan yazılıyor** (chunk4 §4.1, chunk5 D-6) | Şifreleme bir Eloquent **cast**'tir; `Model::query()->update([...])`, `DB::table(...)->update([...])` ve `->insert([...])` gibi query-builder yazmaları cast'i **atlar** ve düz metni sessizce kolona indirir — mutlu-yol testi bunu yakalamaz çünkü o model üzerinden yazıp okur (heyet 138 açık soru 1). Tarama: `config('crypto.encrypted_columns')` listesindeki kolon adları `app/` altında bir query-builder yazma çağrısının argümanında geçemez. Kolon listesi **kesindir**; şifreli kolon eklemek listeye ekleme demektir. ⚠ Listenin **doğruluğunu** bu madde değil **S-18** garanti eder — S-16 tek başına elle tutulan bir listeye güvenir |
| S-17 | **Dosyaya yol veya adla erişilmiyor** (chunk4 §4.4 D-2a) | `storage_path` ve `original_filename` **rota/URL üretimine girmez**: taranan yollar `routes/**`, `app/Http/**`, `resources/js/**`; bu yollarda iki kolon adı da geçmez ve `Storage::url(`, `->temporaryUrl(` **ve `disk(…)->url(`** çağrıları **hiçbir yerde** kullanılmaz (üçüncüsü şart: `Storage::disk('x')->url($path)` biçimi ilk kalıba uymaz ve gözden kaçar — heyet 139 notu) (`app/Support/Files/**` dahil — orada da doğrudan URL üretilmez). Dosya tek uçtan, parametreyle ve yetki kontrolünden geçerek döner; yol bilmek erişim vermez çünkü dışarıya bilinecek bir yol verilmez |

| S-18 | **`config('crypto.encrypted_columns')` listesi gerçeği yansıtıyor** (Sets kararı, 26.07.2026 — heyet 139) | S-16 bu listeye **güvenir**, ama listenin doğru olduğunu bugün hiçbir şey doğrulamaz: chunk7'de biri `TenantEncrypted` cast'i taşıyan bir kolon ekleyip listeye yazmayı unutursa S-16 o kolonu **hiç taramaz** ve sessizce geçer — yani en kritik tarama, elle tutulan bir listenin bakımına bağlı kalır. **Senkron bacağı:** cast'i kullanan kolonlar **koddan türetilir** (`app/Models/**` içinde `casts()` dönüşünde `TenantEncrypted::class` taşıyan anahtarlar) ve `config/crypto.php` listesiyle **iki yönlü** karşılaştırılır; koddaki bir kolon listede yoksa **build kırılır**, listede olup kodda olmayan kolon da kırar (ölü kayıt listeyi güvenilmez yapar). Emsal: `config/audit.php` `getTable()` desync kontrolü (chunk3) — aynı desen, aynı gerekçe |

| S-19 | **Backoffice panelinde operasyon ekranı yok** (spec §3.4, kriter 37) | Backoffice paneline kayıtlı Filament kaynakları **kesin bir allowlist**'e karşı kontrol edilir: yalnız çekirdek tanım kaynakları (mevzuat, evrak türü, bildirim şablonu, ayar, eğitim türü, seçim listesi, backoffice kullanıcı yönetimi). Mali tablo, müşteri tablosu veya eğitim operasyonu kaynağı kaydedilirse build kırılır. ⚠ Bu bir **kapsam** taramasıdır, yetki taraması değil — backoffice'in yetki tavanı yoktur (spec §3.3); tarama bugünkü **tercihi** (§3.4) sabitler ve tercih değişirse allowlist ile birlikte değişir. Allowlist'i büyütmek **plan değişikliğidir** |

S-11…S-19, chunk3, chunk4, chunk5 ve chunk8'in zorlamayı bu belgeye devrettiği kurallardır (heyet 130 F-7, heyet 131 R2-10, heyet 138 açık soru 1): "chunk9 taraması denetler" denen her kural §9.5'in **kesin listesinde** bir maddeye karşılık gelmek zorundadır; burada olmayan kuralın zorlaması yoktur.

⚠ **Tarama tarifleri allowlist'lidir, niyet gerektirmez** (heyet 131 R2-4/R2-5). Bir kural "şu dizge şu dosyaların dışında geçmesin" biçiminde yazılamıyorsa §9.5'e girmez — girerse ya meşru bileşenleri kırar ya hiçbir şey yakalamaz. Allowlist'i büyütmek plan değişikliğidir.

⚠ **Sınıf adı arayan kurallar kelime sınırıyla arar** (heyet 133 R4-2). `AuditWrite` dizgesi `AuditWriter`ın ön ekidir; naif `grep AuditWrite` `AuditWriter`ı kullanan her meşru dosyayı (chunk6'nın `Gate::after` kancası, kimlik doğrulama listener'ları) yanlış pozitif olarak kırar — ve allowlist'i büyütmek plan değişikliği olduğu için implementer bunu kendi başına düzeltemez, durup sormak zorunda kalır. S-12 ve S-13'ün model adları **`\bAuditWrite\b` kelime sınırıyla veya `::class`/FQCN biçiminde** aranır. Tablo adları (`audit_writes` vb.) bu sorunu taşımaz.

S-9 özellikle önemlidir: `SECURITY DEFINER` RLS'i bypass eden tek meşru mekanizmadır; envanterinin gözden kaçarak büyümesi izolasyonu sessizce aşındırır.

### 9.6 Rollout

**İlk kurulumdur; production'da üzerine yazılacak veri yoktur.**

**Ön koşul kontrol listesi** (atlanırsa kurulum bozulur):

| # | Kontrol |
|---|---|
| 1 | `deployment/pgbouncer/userlist.txt` **`3eegitim_app` ve `3eegitim_app_financial`** rollerini içeriyor |
| 2 | `.env` içinde `TENANT_ROOT_KEY` tanımlı ve `APP_KEY`'den farklı |
| 3 | S3 bucket'ta public erişim kapalı |
| 4 | **Veritabanında otomatik silen retention job yok** *(Sets, 07.08.2026)*. ⚠ Suite'te de var (`AuditSchemaTest`), ama o **repodaki** şemayı okur; buradaki soru **canlı veritabanının zamanlayıcısına** sorulur — elle psql'den eklenen bir policy repoda hiç iz bırakmaz, ve 07.08.2026'da bulunan da koddan değil veritabanına sorulunca çıktı |
| 4 | **`TreeScopeResolver` (Spec 3) devreye girmeden gerçek müşteri verisi yüklenmez** (chunk6 §6.5 prod çıkış kısıtı) |

**Dağıtım sırası:**

1. DB rolleri migration'ı — `pgsql_admin` ile.
2. PgBouncer `userlist.txt` güncellenir, pgbouncer yeniden başlatılır. **Bu adım atlanırsa uygulama bağlanamaz.**
3. Şema migration'ları (`pgsql_admin`).
4. Seed: izinler, auth yöntem varsayılanları, bildirim şablonları.
5. Uygulama dağıtımı.
6. Supervisor: Horizon + Reverb.
7. Schedule: damgalama, WORM arşiv, süre takibi taraması, günlük giriş özeti.

### 9.7 Rollback

| Durum | Geri dönüş |
|---|---|
| Uygulama hatası | Önceki sürüme dön; şema geriye uyumlu olduğu için migration geri alınmaz |
| Migration hatası | `migrate:rollback` — **yalnız bu chunk'ların migration'ları için güvenli** (ilk kurulum, veri yok) |
| RLS politikası sorunu | Politika **`DROP POLICY` ile kaldırılmaz** — kaldırmak izolasyonu kapatır. Uygulama durdurulur, politika düzeltilir |
| PgBouncer sorunu | `pool_mode` **session'a çekilmez** (AD-04). Bakım moduna alınır |
| Rol/yetki sorunu | Uygulama `postgres` rolüne **düşürülmez** — deney 1'deki sessiz izolasyon kaybını üretir |

⚠ **Kırmızı çizgi:** "geçici olarak RLS'i kapatalım", "uygulamayı postgres ile bağlayalım", "pool_mode'u session yapalım" rollback seçeneği **değildir**. Üçü de izolasyonu kaldırır ve fark edilmez.

**Veri kaybı riski:** bu chunk'ların hiçbirinde kolon silme, yeniden adlandırma veya tip değiştirme **yoktur**. İleride gerekirse kırmızı çizgi 5 uygulanır: DUR ve SOR.

### 9.8 Kabul kriterleri

1. `RlsTestCase` her testte superuser olmadığını doğruluyor.
2. Spec §14'ün **30 kriterinin her biri** için en az bir test var; eşleme tablosu (`SPEC_CRITERIA.md`) eksiksiz.
3. Dış HTTP çağrısı yapan test kasıtlı denemede anında kırılıyor.
4. `omurga-rls` grubu paralel koşmuyor; diğerleri koşuyor.
5. Nightly rastgele sıralı tam koşum tanımlı ve çalışıyor.
6. Karantina mekanizması çalışıyor: tutarsız test gruba alınıyor ve OPS ticket'ı açılıyor.
6a. **Oturum yazmasının bedeli ölçülüyor** — istek başına bir oturum satırı yazması yapılıyor (chunk5 §5.1.1, spec A-2'nin gereği) ve bunun istek gecikmesine katkısı yük testinde **rakamla** raporlanıyor. *(heyet 145 açık soru 4: varsayım kabul edildi, ama ölçümsüz kapanmasın.)*
6b. **Kriter sicilinde `açık` satır kalmadı** — her spec kriterinin kanıtı bir test adına bağlandı. ⚠ `devredildi` satırları istisnadır ve her biri devir tablosunda adresli olmak zorundadır.
7. `rule-scan.sh` **on dokuz kuralı** (S-1…S-19) tarıyor; her biri kasıtlı ihlalle build'i kırdığı gösterilerek doğrulanmış. S-11…S-15'in allowlist'leri planla birebir; listede olmayan dosya eklenince build kırılıyor (pozitif + negatif kontrol). **S-18 iki yönlü** doğrulanıyor: (a) `TenantEncrypted` cast'i taşıyan bir kolon `config/crypto.php`'ye yazılmadan eklendiğinde build kırılıyor, (b) listeye kodda karşılığı olmayan bir kolon yazıldığında da kırılıyor.
8. S-9 envanteri: mevcut `SECURITY DEFINER` fonksiyonlar beklenen listeyle eşleşiyor; listeye eklenmemiş yeni fonksiyon build'i kırıyor.
9. Rollout ön koşul listesi `deployment/` altında script'li; 1. ve 4. maddeler atlanırsa kurulum **anlaşılır hata** veriyor.
10. Tam koşum < 5 dk; grup başına < 60 sn.
11. TeamCity'de tam koşum yeşil; Pint ve PHPStan temiz.

---

## Chunk 10 — Backoffice sistem verisi

**Bağımlılık:** chunk4 (evrak), chunk5 (kimlik), chunk6 (yetki)
**Dayanak:** `design/HANDOFF.md` satır 12 (tasarım kapsamı dışı bırakılan altı kalem = Filament'in işi) · spec §3.4 (backoffice kapsamı: yalnız çekirdek parametreler) · AD-27 (mevzuat kaydı) · AD-08 (iş kuralı koda gömülmez) · `docs/mevzuat-kurallari.md` §11
**Bitiş şartı:** §10.9 kabul kriterleri.

> **Neden bu chunk var.** `design/HANDOFF.md` şunu yazar: *"admin panel Filament'tir (kapsam dışı: kural motoru, parametreler/limitler, fiyat tabloları, eğitim kataloğu tanımı, auth yöntem ayarları, içerik paketi tanımları)"*. Yani tasarımcının kapsam dışı bıraktığı altı kalem, tam olarak bu chunk'ın kapsamıdır. Altısının **dördünün tablosu yoktu** — bu chunk tabloyu, kuralı ve ekranı birlikte üretir.

⚠ **Kırmızı çizgi 14'ün veri yarısı burada kapanır.** *"Azami/asgari katılımcı, sınav eşiği, bildirim süresi, yenileme periyodu mevzuat kaydından gelir; kod içinde sabit sayı anında red."* Mevzuat kaydı bugüne kadar **yoktu** — yani kural yazılıydı ve dayanağı üretilmemişti. İlk eğitim planlama ekranı ilk satırında buna çarpardı.

### 10.1 Mevzuat kaydı (AD-27, mevzuat-kuralları §11)

> ⚠ **Revizyon — Sets kararı, 27.07.2026.** Bu bölümün ilk hâli mevzuat kaydına **makine-okunur bir kısıt seti** koyuyor, eğitim tanımının bunu devralmasını (M-3) ve zincirle çözülmesini (M-4) öngörüyordu. **Kaldırıldı.** İki gerekçe:
>
> **(a) Doldurulamıyor.** Resmî metinler aynı kısıtı eğitim başına farklı yazıyor. YÖN-2019 md.8 iki yıl der, md.5/2 ve md.5/4 görev 11 ve 12 için beş yıl der; TY-2019 Ek-5/2 on satırlık bir tablodur ve içinde iki ayrı periyot vardır (IMDG/IMSBC 2 yıl, diğer sekizi 5 yıl). Mevzuat başına tek bir `renewal_months` bunların hiçbirini yalan söylemeden ifade edemez.
>
> **(b) Bakım.** Genelge veya değişiklik yayımlandığında türetilmiş her kısıt elden geçirilmek zorunda kalır.
>
> **Kökeni bir yorum hatasıdır:** brainstorm'da söylenen *"mevzuattan gelir"*, sorulmadan *"mevzuat kaydından gelir"* diye genişletildi. Kırmızı çizgi 14 özde korunur — sayı koda girmiyor, admin-tanımlı veri olarak kalıyor (AD-08); değişen, sayıyı **hangi kaydın taşıdığıdır**.

Üç katman, `docs/mevzuat-kurallari.md` §11'in tarifiyle birebir:

| Katman | İçerik |
|---|---|
| **Künye** | ad, kısa kod, tür (yönetmelik/yönerge), RG veya Bakan Oluru tarih-sayı, resmî link |
| **Bağlı dokümanlar** | ana metin · yönerge · **genelge** · değişiklik · ek — her biri kendi tarihi, sayısı, resmî bağlantısı ve **dosyası** ile |
| **Uyarılar** | eğitim tanımlanırken ekranda gösterilecek, **madde referanslı** hatırlatmalar |

| # | Kural |
|---|---|
| M-1 | **"MEVZUATTA YOK" da bir uyarıdır.** `docs/mevzuat-kurallari.md`'nin işaretlediği boşluklar (asgari katılımcı sayısı, DGY'de sınav şartı) kayıtta açıkça *"düzenlenmemiştir"* diyen bir uyarı satırı olarak yazılır. Alanı doldurmaya çalışan kişiye böyle bir kural olmadığını söylemek, boş bırakmakla uydurmak arasındaki farkı kuran şeydir (kırmızı çizgi 14, CLAUDE.md kural 4) |
| M-2 | **Uyarı zorlamaz.** Kayıttaki hiçbir alan bir eğitim tanımını reddetmez; uyarı bir cümledir, bir sayı değil. Sayı eğitim tanımına elle girilir. Bir mekanizmanın "uyarır" mı "reddeder" mi olduğu bu satırla belirlenir — reddeden bir kural eklemek plan revizyonudur |
| M-3 | **Her uyarı madde referanslıdır**, ya da referansı olmadığı açıkça görülür. Referansı olmayan uyarı yalnız "MEVZUATTA YOK" sınıfında meşrudur; başka türlüsü denetimde savunulamaz |
| M-4 | **Bağlı doküman türü kolondur**, başlıktan tahmin edilmez: genelge kuralın *uygulanışını* değiştirir, değişiklik *metnin kendisini* değiştirir. Planlama yapan kişi ikisini farklı okur. Dosya ve resmî bağlantı birbirinden bağımsız olarak opsiyoneldir — ikisi de gerçek durumdur |
| M-5 | Bağlamsızdır (chunk2 §2.8) — mevzuat 3E'nin değil devletin verisidir, hiçbir tenant'a ait değildir. Dosyaları da şifrelenmez: chunk4 evrak motorundan geçirmek, her tenant'ın okuduğu bir belgeye tenant anahtarı bağlamak olurdu |

### 10.2 Eğitim kataloğu tanımı

**Dayanak:** `docs/mevzuat-kurallari.md` §0, §3, §4, §5, §12 · müşteri brief'i (`docs/reference/3E_Akademi_...docx`) §5.1–5.3 ve Ek A/Ek B · AD-08.

Üç tablo, çünkü bir eğitim düz bir satır değil:

| Tablo | Ne tutar |
|---|---|
| `training_definitions` | Eğitimin ne olduğu ve **neyi gerektirdiği** — süreler, yenileme periyodu, sınav eşikleri, katılımcı limitleri, bildirim tipi ve süresi, kayıt saklama, eğitmen ön şartı |
| `training_curricula` + `training_topics` | Ne **öğretildiği**, sürümlü. Her sürüm kendisini doğuran mevzuat dokümanına işaret eder |
| `training_coverage` | Bu eğitimi alanın başka **neyi almış sayıldığı** (YÖN-2019 md.7/1) |

| # | Kural |
|---|---|
| E-1 | **Mevzuat zorunludur ve ilk sorulan alandır.** Eğitim formu önce mevzuatı sorar; seçilince o mevzuatın uyarıları ve bağlı dokümanları **sayıların üstünde** görünür. Sıra tesadüf değil: alanlar önce gelirse kişi bellekten yazar |
| E-2 | **Uyarı otomatik doldurmaz.** Mevzuat kaydındaki hiçbir değer bu forma kopyalanmaz ve hiçbir alanı reddetmez. Kişi okur, sayıyı kendisi girer (27.07.2026 revizyonu). Bir alanı mevzuata karşı doğrulayan kod eklemek **plan revizyonudur** |
| E-3 | **Boş alan "mevzuat düzenlememiş" demektir**, sıfır değil. Bu yüzden `exam_required` üç durumludur (düzenlenmemiş / zorunlu / gerekmiyor) ve toggle değildir — toggle boş ile "gerekmiyor"u aynı şeye düşürür |
| E-4 | **Yayımlanmış müfredat geriye dönük değişmez.** Değişiklikte yeni yürürlük tarihli sürüm açılır; verilen eğitim hangi sürümle verildiğini saklayacaktır |
| E-5 | **Kapsama kaydedilir, türetilmez ve geçişli değildir.** Brief'in cümlesi: *"mevzuatta tanımlanmayan eşdeğerlik sistem tarafından türetilmez."* Görev 8, 5 ve 6'yı kapsar; 5 ve 6'nın kapsadıklarını kapsamaz |
| E-6 | **Katalog bağlamsızdır** — 3E'nin ana verisi, her müşteri için aynı. Müşteri kendi "görev 4"ünü almaz |
| E-7 | **Seed demo verisidir**, referans değil. 32 satır brief'ten gelir; 3E gerçek kataloğu kendisi girer ve ekrandan düzeltir. Seeder üzerine yazmaz (`firstOrCreate`) |
| E-8 | **Eğitmen ön şartı satırdır, cümle değil.** `instructor_requirements` ana verisi tanımlanır, eğitim tanımı işaretler. Sebep: atama anında cevaplanacak soru *"bu eğitmen bu eğitimi verebilir mi"* — bu iki listenin karşılaştırılmasıdır, bir paragrafın okunması değil. Her ön şart belgeyle kanıtlanıp kanıtlanmayacağını söyler. ⚠ Karşılaştırmanın eğitmen yarısı **eğitmen modülünde** kapanır: kişi ön şartlarını belgeleriyle taşır, atama iki listeyi karşılaştırır |
| E-9 | **Sayısal alan `type=number` değildir.** Ok tuşları kazara değer değiştirir ve ondalık ayracı tarayıcının diline bağlıdır — 1,5 saatlik sınav süresi tam da kaybolacak değerdir. Alanlar metin girdisidir, harf JS ile süzülür, virgül kabul edilip noktaya çevrilir ve **sunucuda da doğrulanır**: tarayıcının zorladığı her şey tarayıcıdan kapatılabilir |

**Mevzuat kaydına ek (10.1'i tamamlar):** `regulations.parent_id` — *bağlı olduğu* mevzuat. Ardıllıktan (`supersedes_id`) ayrıdır ve karıştırılması yanlış bir şey söyler: genelge, yorumladığı yönetmeliği yürürlükten kaldırmaz, ikisi de yürürlükte kalır. Mevzuat türü beş seçenek: yönetmelik · yönerge · genelge · ek · diğer.

### 10.4 Evrak türleri ve yönetilen listeler (AD-24)

*Sıradan öne alındı (Sets, 27.07.2026): `documents.document_type_id` chunk4'ten beri hedefsiz duruyordu ve eğitmen ön şartının "belge gerekli" bayrağı hangi belge olduğunu söylemeden yarım kalıyordu.*

| # | Kural |
|---|---|
| D-1 | **Kapsam sert bir bölmedir.** Bir belge türü YALNIZ kapsamındaki ekranlarda seçenek olarak çıkar, başka hiçbir yerde görünmez. Kapsam listesi **kodda sabittir** (eğitmen · ofis kullanıcısı · müşteri firma · katılımcı · eğitim tanımı) — bunlar üründeki *yerlerin* adıdır ve veri girişiyle yeni bir yer yaratılamaz. Çoklu seçim "her yerde çıkar" demek değildir: kimlik üç kapsamdadır ve müşteri firma ekranında yine görünmez |
| D-2 | **Alanlar tiplenmiş ve sabittir** (AD-24). Türün karar verdiği şey hangi alanların *görüneceği* ve kimlik alanının *ne diye adlandırılacağıdır*. İşaretlenmemiş alan formda hiç yoktur — boş görünüp doldurulmaz değil. Gerçekten yeni bir veri tipi bir nullable kolon + bir migration'a mal olur; AD-24 bu bedeli tartıp kabul etmiştir |
| D-3 | **Karara girebilecek her değer seçilir, yazılmaz.** Serbest metin geriye dönük düzeltilemez: aynı bölüm üç farklı yazımla girildiğinde hiçbir sorgu bunları birleştiremez ve hiçbir migration bunu onaramaz. Bu yüzden değerler `option_lists`'e bağlanır. ⚠ Korunan şey depolama biçimi değil, **söz dağarcığıdır** |
| D-4 | **Liste iki katmanlıdır**, çünkü metinler iki düzeye referans verir: DG-2020 md.14/6 *fakülte* der, CSB 2023/4 md.4 *bölüm* der. Üçüncü katman yoktur — "X'in altındaki her şey" sorgusunun sınırlı kalması için |
| D-5 | **Seed başlangıç listesidir, ulusal kayıt değil.** Yalnız `mevzuat/` içindeki metinlerin adını andığı değerler girilir; elimizde olmayan bir YÖK dökümünü uydurmak, "bu eğitmen mühendistir" kararının dayanağını uydurmak olur. Eksiklik ekranda kapatılır: diploma hem **liste değerini** hem **program adını serbest metin** taşır |
| D-6 | **Nitelik belgeden okunmaz, ön şart olarak tanımlanır.** *"Mühendislik fakültesinden mezun"* bir belge alanı değil bir yeterliliktir. Taranmış diploma makine tarafından doğrulanamaz; insan bakar, ön şartı doğrulanmış işaretler, atama bunu kontrol eder. ⚠ **JSON'daki ya da serbest metindeki hiçbir değer karara girdi olamaz** |
| D-7 | Tür ve liste değeri **silinmez, pasifleştirilir** (AD-11). Altında evrak olan tür, işaret edilen liste değeri kaydı değiştirmeden kaldırılamaz |
| D-8a | **Eğitmen ön şartı listesi seçilen mevzuata göre filtrelenir.** Varsayılan: o mevzuatın şartları + mevzuata bağlı olmayanlar. Bir eğitim, alakasız bir ailenin şartlarıyla dolu bir liste görmez. ⚠ **Filtre kilit değildir** — bir anahtarla diğer mevzuatların şartları da açılır, çünkü TY-2019 Ek-5/3.1 IMDG ve CTU eğitmenlerini YÖN-2019'a havale eder; katı filtre doğru seçeneği ulaşılamaz kılardı |
| D-8b | **Ekranlar aynı kayıt için aynı metni gösterir.** Eğitim ekranında ön şartın adı, ön şart ekranındaki adla birebir aynıdır; mevzuat ve madde ada karıştırılmaz, ikinci satırda **künye** olarak durur. Kaydın içinde bulunmayan bir etiket, uydurma veri gibi okunur (Sets bildirimi, 27.07.2026) |
| D-8 | **Eğitmen ön şartı da mevzuata bağlanır** (`regulation_id` + `article_reference`). `docs/mevzuat-kurallari.md` §7'deki her satırın maddesi vardır; referanssız ön şart denetimde savunulamaz. ⚠ **Nullable:** 3E'nin kendi operasyon kuralının maddesi yoktur ve uydurulmaz. ⚠ Bağ **sıralar, kısıtlamaz** — TY-2019 md.17/4 geri düşme kuralı yüzünden bir mevzuatın eğitimi başka mevzuatın şartını gerektirebilir; katı filtre doğru seçeneği ulaşılamaz kılardı |
| D-9 | **Bir belge türü birden fazla tarafa ait olabilir.** Diploma hem eğitmene hem katılımcıya lazım: DG-2020 md.14/6 eğiticiden mühendislik mezuniyeti ister, TY-2019 md.12/3 IBC/IGC/MARPOL gözetim yetkilisinden lisans diploması ister, DG-2020 md.14/2 muafiyeti diplomayla iddia edilir. İkisinin alan ihtiyacı ayrışırsa çözüm **iki ayrı tür**dür, tek türü esnetmek değil (Sets, 27.07.2026) |

**Bu turda yapılmayan (AD-24'ün kalanı, kendi turunda):** onay akışı (yükleyen ≠ onaylayan, dört göz) · şablon sürümleme + değişiklik etki analizi (*"bu değişiklik 47 evrakı etkiliyor"*) · evrak talebi görevi akışı · zorunluluk tanımları (`gereklilikler`: bağlam × evrak). Bunlar chunk4'ün yükleme akışına dokunuyor.

**Kapandı — sahip başına ayrı evrak tablosu (Sets, 27.07.2026).** Açık soru *"eğitmen belgeleri hangi tabloda"* idi; cevap: **kendi tablosunda.** Tanım ortak (`document_types`), satırlar sahibine göre ayrı: `instructor_documents`, `participant_documents`, `customer_documents`, `office_user_documents`… Gerekçe RLS'in tablo başına tekdüze olması — bir tablo hem tenant'lı hem bağlamsız olamaz (chunk 2). Emsal spec kriter 31: üç kimlik alanı hiçbir tablo paylaşmaz. Policy/gate/izin kontrolleri de tek sahipli tabloda çok daha temiz kuruluyor.

| # | Kural |
|---|---|
| D-10 | **Her sahip varlık kendi `<sahip>_documents` + `<sahip>_document_versions` tablosunu taşır.** Hepsi aynı çekirdek kolonları tekrarlar çünkü hepsi aynı şablondan üretilir — tekrar bilinçli bedeldir |
| D-11 | **Sahip tablosu kendi modülüyle doğar.** Sahibi olmayan evrak tablosu yabancı anahtar taşıyamaz; bölmenin yarı sebebi budur. Eğitmen/katılımcı/ofis personeli tabloları o modüller yazılırken açılır |
| D-12 | **Kapsam listesi ile tablo listesi kilitlidir.** Bir sahip tablosu varsa o sahip için kapsam da olmalı, yoksa oraya hangi belge türünün gireceği söylenemez |

⚠ **chunk 4 düzeltildi:** tek polimorfik `documents` tablosu `customer_documents`'a daraltıldı, `documentable_type`/`documentable_id` kaldırıldı, sürüm tablosu ve trigger'ları taşındı, RLS politikaları yeniden uygulandı. Şifreleme, imzalı tek kullanımlık bağlantı, erişim logu ve kritik-kayıt silme reddi testleri korundu (26 test). Müşteri lokasyonları (liman, ofis, depo, üretim yeri) bu projede evrak taşımıyor — kapsam açılmadı.

### 10.3, 10.5–10.8 Kalan kalemler

Sırayla ve aynı desenle (tablo + kural + ekran birlikte): parametre ve limitler (indirim tavanı + satışçı override, masraf limitleri — çatı §182, §187) · evrak türleri (chunk4'ün `is_special_category` / `is_critical` bayrakları buradan kopyalanır, AD-24) · auth yöntem ayarları (tablolar chunk5'te doğdu, ekranı yok) · bildirim şablonları (AD-17: uygulama içi, sürümlenebilir, audit'lenebilir) · rıza metinleri (`consent_texts`, chunk4) · ofis kullanıcı provisioning + backoffice log ekranları.

⚠ **Fiyat tabloları ve içerik paketi tanımları bu bölümde henüz tarif edilmemiştir.** Çatı §181 ve §209 ikisine de birer cümle ayırıyor; kalem yapısı, dikey/müşteri-özel tablo ilişkisi ve "içerik paketi"nin ne olduğu tanımlı değil. Sıranın sonundalar ve oraya gelindiğinde **sorulacaktır** — varsayımla doldurulmayacaktır.

### 10.9 Kabul kriterleri

*Kriter 1–4, 27.07.2026 revizyonuyla yeniden yazıldı; eski hâlleri kaldırılan kısıt setini ölçüyordu.*

1. Mevzuat kaydı künye + bağlı dokümanlar + uyarılar taşıyor; kayıtta hiçbir limit/eşik/periyot kolonu yok (test).
2. `docs/mevzuat-kurallari.md`'nin "MEVZUATTA YOK" işaretlediği her boşluk, ilgili mevzuatın uyarı listesinde açıkça *"düzenlenmemiştir"* diyen bir satır olarak duruyor (test).
3. Referanssız uyarı yalnız "MEVZUATTA YOK" sınıfında bulunuyor; başka referanssız uyarı yok (test).
4. Bağlı doküman dosya ile, resmî bağlantı ile veya ikisiyle birlikte kaydedilebiliyor; dosyası olmayan satır disk bilgisi taşımıyor (test).
5. `mevzuat/` arşivindeki dört kaynak (Y-2016, YÖN-2019, TY-2019, DG-2020) kayıt olarak giriliyor; her birinin uyarıları ve bağlı dokümanları var ve her uyarının sayısı resmî metinden birebir doğrulanmış (seed + test).
6. Backoffice panelinde mevzuat kaynağı `regulation.view` izniyle kapılı; izinsiz kullanıcı menüde görmüyor ve URL ile erişemiyor.
7. DGY'nin **onay kapısı** (DG-2020 md.14/8 — onaysız eğitim geçersiz) uyarı listesinde birinci sırada duruyor: diğer ailelerden farkı budur ve kaybolduğunda eğitim IMDG gibi planlanıp geçersiz olur (test).
8. Mevzuat kaydının **sürüm silsilesi** üç olayı ayrı tutuyor: metnin yürürlüğe girmesi (sürüm), değiştirilmesi (yeni sürüm), yenisinin öncekini yürürlükten kaldırması (ardıllık). Bir tarih verildiğinde o gün yürürlükte olan sürüm tekil olarak çözülüyor (test).
9. Eğitim tanımı bir mevzuata bağlanmadan var olamıyor; ekranda mevzuat seçilince o mevzuatın uyarıları ve dokümanları **sayı alanlarının üstünde** görünüyor (test — Livewire mount).
10. Aynı mevzuatın altında **farklı yenileme periyotları** yaşayabiliyor: YÖN-2019'da görev 1 → 24 ay, görev 11 ve 12 → 60 ay; TY-2019'da IMDG → 24 ay, IBC → 60 ay (test). Bu, kısıt setinin kaldırılma gerekçesinin kanıtıdır.
11. Mevzuatın düzenlemediği alan boş kalıyor: DGY'de azami katılımcı ve sınav zorunluluğu NULL, hurda yük eğitiminde süre NULL ama periyot 60 ay (test).
12. Kapsama kaydediliyor, türetilmiyor ve geçişli değil: görev 8 → 5 ve 6; görev 13 → 9 ve 4; görev 13, görev 7'yi kapsamıyor (test).
13. Müfredat sürümlü ve tarihle çözülüyor; sürüm kendisini doğuran mevzuat dokümanına işaret edebiliyor (test).
14. Backoffice panelinde eğitim kataloğu `training.view` izniyle kapılı; izinsiz kullanıcı menüde görmüyor ve URL ile erişemiyor (test).
15. Belge türü **yalnız kapsamındaki** yerde teklif ediliyor: eğitmen kapsamındakiler eğitim evraklarında, eğitim kapsamındakiler eğitmen formunda görünmüyor; çok kapsamlı bir tür bile kapsamı dışına çıkmıyor (test).
16. Pasifleştirilen belge türü hiçbir kapsamda teklif edilmiyor ama kaydı duruyor (test).
17. Türün işaretlemediği alan formda yok; diplomada geçerlilik bitişi kapalı, eğitim alanı açık ve numara alanı kendi adını taşıyor (test).
18. `is_special_category` ve `is_critical` seed'de doğru türlerde: sağlık raporu özel nitelikli, imzalı yoklama kritik (test).
19. Yönetilen liste iki katmanlı ve "bir fakültenin altındaki her şey" tek sorguyla çözülüyor; başka fakültenin bölümü içine girmiyor (test).
20. Eksik listeye çıkış var: eğitim alanı listesinde "Diğer" değerleri duruyor (test).
21. Eğitmen ön şartı **hangi belgenin** kanıtladığını söylüyor ve o belge **eğitmen kapsamında** (test). Mühendislik mezuniyeti bir belge alanı değil, kendi ön şartı olarak duruyor (test).
22. Belge türü ve yönetilen liste `document_type.view` izniyle kapılı; ikisi de silinemiyor (test).
23. Her eğitmen ön şartı bir mevzuata ve bir maddeye bağlı; seed'deki hiçbiri referanssız değil (test).
24. Diploma hem eğitmen hem katılımcı kapsamında, ama müşteri firma kapsamında değil (test).
25. Eğitim ekranındaki ön şart listesi seçilen mevzuata göre filtreleniyor; anahtar açılınca diğerleri de geliyor ve ancak o zaman tamamı listeleniyor (test).
26. Ön şartın eğitim ekranındaki adı, ön şart kaydındaki adla birebir aynı; mevzuat ve madde açıklama satırında künye olarak duruyor (test).

---

## Kriter sicili — spec kriteri → mekanizma → kanıt

**Sets kararı, 27.07.2026.** Kabul kriterleri bugüne kadar chunk'lara bağlıydı ve bir kriterin kanıtı başka bölüme kaydığında bu **plan revizyonu** oluyordu: çok dosyalı cerrahi, her seferinde bir heyet turu. Katman sıralı bir planda mekanizmanın tüketicisinden önce doğması kaçınılmaz olduğuna göre, kaymanın kendisi de kaçınılmazdı — pahalı olan kayma değil, **kaymayı kaydetme biçimiydi**.

Sicil bunu tersine çevirir: kriterin **mekanizmasını kuran bölüm** ile **kanıtlayan bölüm** ayrı sütunlardır. Kanıt başka bölüme kayarsa **tek hücre** değişir.

⚠ **Bu sicil, chunk9 §9.2'nin devrettiği eşlemenin kendisidir** — ayrı bir `SPEC_CRITERIA.md` yazılmaz; orada biriktirilecek iş buraya, gün gün tutulacak biçimde alındı.

**Durum sözlüğü:** `kapalı` = bugün koşan bir test kanıtlıyor · `açık` = mekanizma veya kanıt henüz üretilmedi · `devredildi` = kanıtın adresi başka tura taşındı, gerekçesi devir tablosunda.

| # | Spec kriteri (kısa) | Mekanizma | Kanıt | Durum |
|---|---|---|---|---|
| 1 | Çapraz tenant erişimi veri döndürmez | Chunk 2 §2.4 | Chunk 2 R-1, R-2 | kapalı |
| 2 | Bağlam transaction dışında yaşamaz | Chunk 2 §2.5 | Chunk 2 R-3, R-11 | kapalı |
| 3 | Auth yöntemleri kimlik alanı bazında | Chunk 5 §5.3 | Chunk 5 kriter 9, 25 | kapalı |
| 4 | MFA zorunlu roller MFA'sız erişemez | Chunk 6 §6.4(a) | Chunk 6 kriter 7 | açık |
| 5 | Magic link tek kullanımlık, katılım kanıtı değil | Chunk 5 §5.4 | Chunk 5 kriter 12 | kapalı |
| 6 | Kritik işlem audit üretir, yazılamazsa işlem olmaz | Chunk 3 §3.2 | Chunk 3 kriter 1, 5 | kapalı |
| 7 | Hassas kayıt görüntüleme erişim logu üretir | Chunk 3 §3.5 | Chunk 3 kriter 8 | kapalı |
| 8 | Kullanım logu saklaması backoffice'ten ayarlanır | Chunk 3 §3.9 | Chunk 3 kriter 17 | kapalı |
| 9 | Analitikte kişi kırılımı yok, k-anonimlik 2 | Chunk 8 §8.5 | Chunk 8 kriter 9 | açık |
| 10 | Referans kodu rastgele, tekrarsız, yetki değil | Chunk 6 §6.7 | Chunk 6 kriter 12, 14 | açık |
| 11 | Promosyonel gönderim onaysız yapılamaz | Chunk 7 §7.3 | Chunk 7 kriter 5 | açık |
| 12 | Kritik bildirim kapatılamaz | Chunk 7 §7.2 | Chunk 7 kriter 4 | açık |
| 13 | Anlık bildirim çalışmazsa iş akışı bozulmaz | Chunk 8 §8.4 | Chunk 8 kriter 14 | açık |
| 14 | Prod dışı ortamda kişisel veri maskeli | Chunk 4 §4.6 | Chunk 4 kriter 9 | kapalı |
| 15 | Aynı kişi iki tenant'ta — kayıtlar ilişkilenmez | Chunk 5 §5.1 | Chunk 5 kriter 3 | kapalı |
| 16 | Davet bazlı eğitim davetsiz tenant'a görünmez | Chunk 6 §6.6 | Chunk 6 kriter 11 | açık |
| 17 | Log yazılamazsa hassas veri gösterilmez | Chunk 3 §3.5 | Chunk 3 kriter 5 | kapalı |
| 18 | Belgeye yalnız süreli/tekil bağlantıyla erişilir | Chunk 4 §4.4 | Chunk 4 kriter 8 + Chunk 6 kriter 18 | kısmen |
| 19 | İYS bildirimi | Chunk 7 §7.3 | Chunk 7 kriter 6 | açık |
| 20 | Kapsam dışı kodla arama "bulunamadı" | Chunk 6 §6.7 | Chunk 6 kriter 13 | açık |
| 21 | Panel yetkisiz kişi URL ile de erişemez (U-1) | Chunk 6 §6.3 | Chunk 6 kriter 7b | açık |
| 22 | Eğitim kaydı olmayan katılımcı ekranını görmez | Chunk 5 §5.2 | Chunk 5 kriter 5 | kapalı |
| 23 | Yetki verilince yeni hesap açılmaz, veri taşınmaz | Chunk 5 §5.2 | Chunk 5 kriter 6 + Chunk 6 kriter 15 | kısmen |
| 24 | Yetki değişikliği audit + bildirim (U-5) | Chunk 6 §6.8 | Chunk 6 kriter 15 + Chunk 7 | açık |
| 25 | Magic link oturumunda panelde step-up | Chunk 5 §5.3 | Chunk 6 kriter 7c | açık |
| 26 | Bootstrap yalnız asgari alan döndürür | Chunk 5 §5.5 | Chunk 5 kriter 14 | kapalı |
| 27 | Başarılı/başarısız deneme audit üretir | Chunk 5 §5.5 | Chunk 5 kriter 16 | kapalı |
| 28 | Eksik çeviri boş ekran üretmez | Chunk 1 §1.4 | Chunk 1 kriter 4 | kapalı |
| 29 | Rıza kaydı metin sürümü + dili olmadan olmaz | Chunk 4 §4.5 | Chunk 4 kriter 7 | kapalı |
| 30 | Tenant saat dilimi uygulanır, kayıt UTC | Chunk 1 §1.5 | Chunk 1 kriter 6 | kapalı |
| 31 | Üç alan hiçbir tablo paylaşmaz (kimlik + izin) | Chunk 5 §5.1 + Chunk 6 §6.1 | Chunk 5 kriter 20 + Chunk 6 kriter 2 | kısmen |
| 32 | Y-4/Y-5 üç alanda zorlanır | Chunk 6 §6.3 | Chunk 6 kriter 2b (backoffice), 2f (ofis), 7b (müşteri) | açık |
| 33 | Backoffice'in tenant verisine her dokunuşu loglanır | — | — | **devredildi** (devir tablosu) |
| 34 | Backoffice denetim okuması ayrı izinle + loglanır | Chunk 3 §3.6.1 | Chunk 3 kriter 12 | kapalı |
| 35 | Backoffice alanında MFA zorunlu | Chunk 5 §5.7(b) | Chunk 5 kriter 24 | kapalı |
| 36 | Kimlik alanları arası kayıtlar ilişkilenmez (I-5) | Chunk 5 §5.1 | Chunk 5 kriter 23 | kapalı |
| 37 | Backoffice panelinde operasyon ekranı yok | Chunk 9 S-19 | Chunk 9 kriter 7 | açık |
| 38 | Pasifleştirme açık oturumları anında düşürür | Chunk 5 §5.6 A-5a | Chunk 5 kriter 17 | kapalı |

⚠ **Sicilin bakımı kuraldır, tercih değil.** Bir chunk'ın implementasyon turu, dokunduğu satırların `durum` sütununu güncellemeden kapanmaz; heyet matrisi bu sicile karşı üretilir. Sicilde `açık` kalan bir satır, o kriterin hiçbir yerde kanıtlanmadığı anlamına gelir — *"başka chunk'ta vardır herhalde"* cevabı bu tablonun var olma sebebiyle çelişir.

---

## Devir kuralları — henüz yüzeyi olmayan spec kuralları

Bir spec kuralı, ölçülecek yüzey doğmadığı için hiçbir chunk'ta kriter olamıyorsa **ertelenmiş sayılır ve adresi burada yazılır**. Adressiz erteleme, chunk9'un meta-kuralına takılır: *"burada olmayan kuralın zorlaması yoktur"*.

| Kural | Neden bugün ölçülemiyor | Yükümlülük |
|---|---|---|
| **Spec kriter 33** — backoffice'in tenant verisine her dokunuşu log üretir (okuma → erişim logu §7.2, yazma → §7.1 audit'i) | Omurga backoffice'ten tenant **iş verisine** giden hiçbir yol üretmiyor; tek çapraz-tenant yol denetim feed'idir ve o zaten kriter 34 ile kapalı (chunk3 §3.6.1). Ölçülecek dokunuş yok | ⚠ **Backoffice'ten tenant verisine yol açan her gelecek chunk veya spec, log bağını kendi kabul kriterine almak zorundadır** — yolu açan belge kriteri de yazar. Bu satır o yükümlülüğün kaydıdır; ilk yol açıldığında buradan düşer ve ilgili chunk'a taşınır (heyet 143 F-1) |

| **E-postaya göre arama** — `email_hash` eşitliği, tenant kapsamı içinde; kısmi/`LIKE` yok (chunk5 §5.1) | Omurga arama yüzeyi üretmiyor; ilk müşteri/personel arama ekranı dikey planlarda doğuyor | ⚠ **Arama yüzeyi üreten her plan bu kuralı kendi metnine alır.** Almayan plan, ilk ekranda "çalışmıyor" ile karşılaşır ve düz metin bir yardımcı kolon eklemeye zorlanır — F-2'nin kapattığı şeyin arka kapıdan dönüşü (heyet 146 açık soru 4) |

| **`CreateCustomer` + müşteri açma ekranı** | Chunk6'nın kapsamı yetki zorlamadır; müşteri açma bir iş ekranıdır ve tasarımdaki ofis modüllerinden biridir (Sets kararı, 27.07.2026) | **Spec 3 / ilk dikey modül.** ⚠ **Panel kararı şimdiden verildi: müşteri açma OFİSİN işidir ve ofis ekranında yapılır** (Sets). Backoffice'e konması spec §3.4, kriter 37 ve chunk9 S-19 ile çatışır. O turda ayrıca chunk6 A-4'ün boşluğu kapanır: tenant'ın varsayılan rol tanımları tenant doğarken yazılmalı |
| **V-3'ün çatışma yarısı** ("bu kayıt üzerinde başka biri çalışıyor" uyarısı) ve **R-7'nin "her panelde koda göre arama" yüzeyi** | Omurga ekran üretmiyor; ikisi de bir arayüz gerektiriyor | Yüzeyi doğuran **dikey plan** kuralı kendi metnine alır. Mekanizma chunk6'da (`VisibilityRule`, `FindByReferenceCode`), yüzey orada |

⚠ Bu tablo **kısa tutulur**. Bir kural buraya yazılıp unutulursa S-16 emsalindeki *"kural yazıldı ama kimse zorlamıyor"* sınıfına düşer; fark yalnız, düşüşün görünür olmasıdır.

## Kapsam dışı

Spec'in non-goal'ları aynen geçerlidir. Ek olarak bu plan **iş ekranı üretmez**: panel iskeleti, giriş akışı ve backoffice'in omurga ekranları dışında Filament resource'u, Inertia sayfası veya iş formu yazılmaz.

**Otomatik veri silme/anonimleştirme yoktur** (spec v1.3 sahip kararı) — hiçbir chunk'ta saklama süresi dolduğunda çalışan iş yazılmaz. chunk9 §9.5 bunu tarayarak denetler.

## Açık noktalar

Spec §15'teki beş açık nokta implementasyonu **bloklamaz**; ilgili chunk'larda varsayılan değer + admin ayarı olarak ele alınmıştır (chunk5 §5.6, chunk7 §7.7). Planın kendi açık noktası yoktur.

---

## Revizyon geçmişi

**2.6 — 27.07.2026.** Chunk 6 implementasyonu sırasında ortaya çıkan üç plan düzeltmesi:

| # | Düzeltme |
|---|---|
| **1** | İzin ailesi başına **dört değil beş** tablo (§6.1). Eksik olan rol↔izin pivotuydu; onsuz rol kavramı çalışmıyor. Bağlamsız iki ailenin pivotu muafiyet listesine eklendi (33 → 35). |
| **2** | Y-3'ün audit tetikleyicisi (§6.3). `Gate::after` **yazmıyor, hatırlıyor**; satır `AuthorizationException` anında yazılıyor. Aksi halde gizlenen her menü kalemi bir audit satırı üretirdi — soru ile durdurulmuş deneme aynı şey değil. |
| **3** | A-4 kapatıldı (§6.1.1): tenant rol tanımları `ProvisionTenantAuthorization` ile müşteri satırıyla aynı transaction'da yazılıyor. Varsayılan rol seti (`owner`) **Sets'in onayını bekliyor**. |

**2.1 — 25.07.2026.** v2.0 plan review'unun (`review-omurga-plan-2026-07-25-r2.md`) sekiz bulgusu kapatıldı:

| Bulgu | Yapılan |
|---|---|
| **F1** (majör) `WITH CHECK` DELETE'e uygulanmıyor; çok-tenant kapsamla silme mümkündü | **Canlı ortamda doğrulandı** (deney 8: tek politikayla `DELETE` geçti, 3 satır → 2). chunk2 §2.4 **komut bazlı dört politikaya** çevrildi: `FOR SELECT` liste, `FOR INSERT/UPDATE/DELETE` tek tenant. Düzeltme de denendi (deney 9: `ERROR: write needs single tenant`). R-5b ve R-5c testleri eklendi. |
| **F2** (majör) Ofis kapsam listesinin kaynağı tavuk-yumurta; `customers` satırı çelişkili | chunk2 §2.8.1 eklendi: nihai kaynak **ofis tarafındaki organizasyon tabloları** (bağlamsız, tavuk-yumurta yok); geçici çözüm için dar `app.all_customer_ids()` fonksiyonu, S-9 envanterinde **"geçici"** işaretli. `customers` satırı düzeltildi. |
| **F3** (majör) Ofis olaylarının `audit_writes` tenant durumu tanımsız | chunk3 §3.6'ya **NULL disiplini** (N-1…N-3) yazıldı: üç audit tablosunda da `tenant_id` nullable, NULL yalnız ofis alanı olaylarında, okuma policy ile kısıtlı. Üç yeni kriter. |
| **F4** (majör) `magic_links` ve istek-içi bağlam kurulumu eksik | (a) chunk2 §2.8.2 `app.magic_link_lookup()` — altı alanla sınırlı, aynı "istisna işleme aittir" deseni. (b) chunk2 §2.5.1 **`EnsureTenantContext` middleware'i** tanımlandı: kapsamı hesaplar, **transaction açmaz**; muaf rota listesi kesin; oturumdan tenant okuma ve uyuşmazlık davranışı yazıldı. |
| **F5** (minör) İç içe `TenantContext` semantiği yarım | §2.5'e dört maddelik kesin semantik: yeni transaction açmaz · alt küme zorunlu (`TenantScopeWideningException`) · daraltmada `SET LOCAL` yeniden · çıkışta **`finally` ile geri yükleme**. R-11b testi. |
| **F6** (minör) chunk5'in üç kriteri chunk6'nın izin altyapısına dayanıyordu | U-1 ve step-up kriterleri chunk6 §6.9'a taşındı (7b, 7c); chunk5'te yalnız kendi ürettiği şeyler sınanıyor. |
| **F7** (minör) Mali bağlantı mekaniği eksik | §2.2'ye `pgsql_financial` bağlantısı; §2.5'e bekçi kancasının **iki bağlantıya birden** kaydedileceği. |
| **F8** (not) `EnsureTenantContext` tanımsız, kozmetik etiket hatası | Middleware §2.5.1'de tanımlandı; chunk9 §9.7'deki yanlış etiket kaldırıldı. |

**2.2 — 25.07.2026.** v2.1 review'unun (`review-omurga-plan-2026-07-25-r3.md`) dört bulgusu kapatıldı. Hepsi **canlı ortamda doğrulandı** (deney 10-12):

| Bulgu | Yapılan |
|---|---|
| **N1** (orta) Audit tablolarında UPDATE/DELETE politikası yokluğu default-deny üretiyor; trigger ateşlenmiyor, ihlal yine sessiz | **Deney 10'da doğrulandı:** politikasız UPDATE hata vermedi, sessizce 0 satır. `audit_writes`/`audit_access` için `FOR UPDATE USING (true)` ve `FOR DELETE USING (true)` eklendi; düzeltme denendi ve trigger'ın `RAISE EXCEPTION` ettiği görüldü. Kriter 2 buna göre netleştirildi. |
| **N2** (majör) Ofis NULL-audit yazımı bekçiye ve `write_tenant_id()` değerlendirmesine takılıyor | (a) chunk2 §2.5'e **dar bekçi muafiyeti**: yalnız üç audit tablosuna, yalnız INSERT, yalnız `AuditWriter`/`AccessLogger` yolundan; SELECT her zaman bağlam ister. (b) **Deney 11 önemli:** iki ayrı permissive politikaya bölmek işe YARAMADI — PostgreSQL her iki `WITH CHECK`'i de değerlendirdi ve fırlatan fonksiyon patladı. Çözüm `app.write_tenant_id_or_null()` (fırlatmayan varyant, yalnız audit politikalarında). Deney 12'de dört senaryo da doğrulandı. |
| **N3** (majör) `customers`'a dört politika uygulanması yeni müşteri yaratmayı imkânsızlaştırıyor | `customers_insert` politikası `WITH CHECK (true)` olarak ayrıldı, gerekçesiyle: yeni satır oluşturmak mevcut hiçbir tenant'ın verisini açığa çıkarmaz; buradaki risk yetkisiz *oluşturma*dır ve yetki uygulama katmanının işidir. DELETE politikası **bilinçli olarak yok** — default-deny silmeyi imkânsız kılıyor, AD-23 ile uyumlu. |
| **N4** (orta) Geniş kapsamlı okumada erişim logu yazımı tek-tenant `WITH CHECK`'e takılıyor | chunk3 §3.5: `AccessLogger` log satırını **ilgili kaydın tenant'ına daraltılmış iç bağlamda** yazar (`forWrite`, F5'in iç içe semantiği). Kriter 2c eklendi. |

**2.3 — 25.07.2026.** v2.2 review'unun (`review-omurga-plan-2026-07-25-r4.md`) iki bulgusu kapatıldı:

| Bulgu | Yapılan |
|---|---|
| **M1** (minör) `LogUsage` bekçi muafiyetinin bileşen listesi dışında; yazım bağlamı tanımsız | Muafiyet listesine `UsageLogger` eklendi (middleware yazımı doğrudan yapmaz, bu bileşeni çağırır). chunk3 §3.5'e **yazım bağlamı tablosu**: müşteri isteğinde `forWrite($tenantId)` ile daraltılmış kısa iş birimi, ofis isteğinde NULL + muafiyet. Kriter 9b eklendi — "gevşek garanti, hiç yazılmıyor demek değildir". |
| **M2** (minör) Kriter 6'nın genel iddiası bilinçli istisnalarla çelişiyor | Kriter 6'ya **istisna tablosu** eklendi (`customers`, `audit_writes`/`audit_access`, `audit_usage` — her biri kendi kriteriyle sınanır). Şema doğrulama testi istisna listesini bilir; listede olmayan sapma testi kırar. **İstisna eklemek plan değişikliği gerektirir.** |

**2.4 — 26.07.2026 (EGT-50 — Sets kararları + heyet review 130/131).** Ofis/backoffice ayrımı AD-09'da eksiksiz tanımlanınca chunk3, chunk8 ve chunk9 revize edildi; upstream pin **v1.9**'a alındı.

| Değişiklik | Nerede |
|---|---|
| `audit_activity` → **`audit_usage`** (`LogActivity`→`LogUsage`, `ActivityLogger`→`UsageLogger`, retention anahtarı) | chunk1 §1.3, chunk2 §2.5/§2.12, chunk3, chunk4 §4.6, `config/audit.php`, `SchemaConformanceTest` |
| **N-4 aktör ayrımı** — ofis feed'i `office_user` + `system`; `participant` ayrı tür değil (spec §3.0) | chunk3 §3.6 |
| **§3.6.1 backoffice çapraz-tenant okuma** — ilk taslaktaki "tenant listesini numaralandır" yolu kurulamadığı için (heyet F-4) **bağlam beyanı** desenine geçildi: `app.audit_read_all()` + fırlatmayan `app.tenant_ids_or_empty()`, B-1…B-4 kuralları | chunk3 §3.6.1 |
| **S-11…S-14** — chunk3'ün chunk9'a devrettiği dört tarama, §9.5'in kesin listesine eklendi (heyet F-7); kriter 7 "on dört kural" | chunk9 §9.5 |
| Kabul kriterleri **15–20** | chunk3 §3.10 |

Heyetin kalan iki bulgusu **Sets kararıyla** kapandı: **F-2** → L-1a (son aktif damgası ofiste görünür, tek alanla sınırlı istisna — chunk8 §8.5'e işlendi). **F-3** → L-1b (kimlik doğrulama olayları ofis feed'inde kalır; kırmızı çizgi 17 üretim tarafında, S-2 ile zorlanır — chunk3 §3.6).

**2.4.1 — 26.07.2026 (heyet review 131, r2 bulguları).** r2 on bulgunun altısını kapalı buldu; kalan beş nokta düzeltmesi + beş minör:

| Bulgu | Yapılan |
|---|---|
| **R2-1** — F-9'un çelişkisi AD'den spec'e taşınmıştı: Y-4 niteliksiz kalmıştı | Spec §6 **Y-4**'e backoffice provisioning'i adlandırılmış tek kalıcı istisna olarak işlendi; kuralın her iki kimlik alanı için geçerliliği açıkça yazıldı |
| **R2-2** (majör) — `app.audit_read_all` kuruluyordu ama geri alınmıyordu; feed'i çağıran Action'ın aynı transaction'daki sonraki audit sorguları bayrağı miras alıyordu | chunk3 §3.6.1 **B-5**: bayrak `AuditReadScope` iş birimiyle kurulur ve **`finally` içinde** kapatılır (chunk2 §2.5 geri yükleme deseni). Kriter **17b-2** transaction-içi mirası ve exception yolunu sınıyor |
| **R2-3** (majör) — feed'in uygulama katmanı zemini yoktu; 17b/17e kurulamıyordu | **B-6/B-7/B-8**: `BackofficeAuditFeed` tenant bağlamı açmaz, bekçi muafiyeti **bileşene** tanınır (chunk2 §2.5 deseni); `OfficeAuditFeed` ortam kapsamıyla koşar; erişim logu ofis alanı olayı olarak NULL tenant ile ve bayrak kapandıktan sonra yazılır. Kriterler 17e/17f/17g |
| **R2-4/R2-5** — S-12 ve S-13 tarifleri taranabilir değildi | İkisi de **allowlist**'e çevrildi (dosya/namespace listesi). §9.5'e kural eklendi: tarif "şu dizge şu dosyaların dışında geçmesin" biçimine indirgenemiyorsa listeye girmez |
| **R2-6** — B-1'in dayandığı S-4 deseni bayrağı görmüyordu | S-4 ve chunk2 §2.5 arch testi **`SET app.*` ailesine** genelleştirildi; `set_config('app.…', …, false)` biçimi de kapsandı |
| **R2-7** — index'in iç tutarsızlığı | Pin v1.9 ve "onay bekliyor" olarak düzeltildi; 2.4 girdisi v1.9 diyor |
| **R2-8** — `heyet.md` L-1a/L-1b'yi bilmiyordu | Güncellendi |
| **R2-9** — chunk3'te yanlış kriter atfı (16 ↔ 15b) | Düzeltildi |
| **R2-10** — chunk8 kriter 9b'nin "arch taraması"nın evi yoktu | chunk9 **S-15** eklendi; kriter 7 "on beş kural" |
| **Açık soru 1** — bayrak neden `audit_usage`'ı kapsıyordu | Kapsamdan **çıkarıldı** (B-3): kullanım logu kimseye satır satır gösterilmez, backoffice agregat okur. Bayrak yüzeyi iki tabloya indi |
| **Açık soru 2** — `CASE` alternatifi | §3.6.1'e değerlendirme ve red gerekçesi yazıldı (hata satır-bağımlı olurdu, deterministik temiz hata vermez) |
| **Açık soru 3** — feed'in erişim logu bağlamı | B-8 ile yazıldı |

**2.4.2 — 26.07.2026 (heyet review 132, r3 bulguları).** Üç majörün üçü de kapalı bulundu; kalan yedi nokta düzeltmesi:

| Bulgu | Yapılan |
|---|---|
| **R3-1** — `AuditReadScope` çıkışta sabit `'off'` yazıyordu; iç içe kullanımda dış scope sessizce bayraksız kalırdı (hata yok, **eksik sonuç**) | **B-5** chunk2 §2.5'in tam desenine çevrildi: önceki değer saklanır, `finally`'de **geri yüklenir**. **B-5a** fonksiyon sözleşmesi: yalnız `'on'` true; `NULL`/`''`/`'off'` false. Kriter **17b-3** (iç içe probu) ve 17b'ye sözleşme testi |
| **R3-2** — chunk2 §2.5'in kesin muafiyet tablosu *"bu tablolara SELECT her zaman TenantContext gerektirir"* diyordu; B-6 ile çelişiyordu | chunk2 muafiyet tablosuna `AuditReadScope` istisnası işlendi (yalnız SELECT, yalnız `audit_writes`/`audit_access`, bileşene tanınır). **Kanun artık tek yerde** |
| **R3-3** — S-12 allowlist'i `config/audit.php`'yi içermiyordu (tarama ilk koşumda kendi config'ini kırardı); kriter 19 eski dar tarifteydi | `config/audit.php` allowlist'e eklendi; kriter 19 S-12'nin diline çekildi |
| **R3-4** — S-13'ün "Inertia sayfa/controller katmanı" tarifi dosya yolu değildi | Dört yol adlandırıldı: `app/Filament/**`, `app/Http/Controllers/**`, `app/Http/Resources/**`, `resources/js/**`. S-14 de bu yollara bağlandı |
| **R3-5** — S-15 "benzeri alan adları" §9.5'in kendi meta-kuralını deliyordu | **Yedi dizgelik sonlu liste**; "listeye ekleme plan değişikliğidir, listede olmayan varyantı yakalamak review'ın işidir" açıkça yazıldı |
| **R3-6** — belge başlıkları v1.9/v2.4 derken revizyon geçmişi v1.9.1/v2.4.1 diyordu | Başlıklar düzeltildi |
| **R3-7** — `analytics_usage_daily`'nin çapraz-tenant okuma mekanizması hiçbir belgede yok (B-3'ün görünür kıldığı önceden var olan boşluk) | `docs/issues.md`'ye kalem: iki muhtemel adres (§2.8 kayıt / G-1 deseninde dar fonksiyon), karar **chunk8 turunda**. Bu revizyonun açtığı bir boşluk değil |

**2.4.3 — 26.07.2026 (heyet review 133 — ✅ APPROVED, 3 minör).** Verdict onaydır; üç minör bulgu yeni tur gerektirmeden düzeltildi:

| Bulgu | Yapılan |
|---|---|
| **R4-1** — sürüm damgası üç turdur bir geride kalıyordu (R2-7 → R3-6 → R4-1): başlık, revizyon girdisi eklenirken değil bir önceki turun değerine güncelleniyordu | Başlık **2.4.2**'ye çekildi ve bu girdiyle birlikte **2.4.3**. Kural: sürüm damgası ile revizyon girdisi **tek adımda** atılır |
| **R4-2** — `AuditWrite` dizgesi `AuditWriter`ın ön ekidir; S-12/S-13 kelime sınırı istemiyordu, `AuditWriter`ı kullanan meşru dosyaları yanlış pozitif kırardı | §9.5'e kural: sınıf adı arayan taramalar `\bAuditWrite\b` kelime sınırıyla veya `::class`/FQCN biçiminde arar |
| **R4-3** — S-11'in "yalnız iki dosya" sınırı, `app.audit_read_all()`'ın nerede CREATE edildiğini söylemiyordu; fonksiyonu chunk2'nin context-functions migration'ına koyan implementer taramayı kırardı | S-11'e ibare: fonksiyonun `CREATE`'i **chunk3'ün audit politika migration'ındadır**; `app.tenant_ids_or_empty()` de chunk3'e aittir |

**2.4.4 — 26.07.2026 (heyet review 134 — F-5 ve F-13, Sets kararları).** İkisi de implementasyon değil **sözleşme/kriter** düzeyindeydi; karar Sets'e sorularak alındı. Kural: *bir standardımız varsa ya ona uyarız, ya standardı doğru tanımlarız — ihlali meşrulaştırmak için standart genişletilmez.*

| Bulgu | Yapılan |
|---|---|
| **F-5a** — `app.audit_batch_merkle_root` `EXECUTE format(... %I ...)` ile dinamik SQL üretiyordu (G-3 ihlali) | **Kural doğruydu, kod düzeltildi.** Fonksiyon zaten `IF p_table NOT IN ('audit_writes','audit_access')` ile tabloları sayıyordu; allowlist yazıldıktan sonra dinamik biçim hiçbir şey kazandırmıyordu. İki sabit dala çevrildi. G-3'e ibare: *allowlist + `%I` quoting bu kuralın istisnası değildir* |
| **F-5b** — aynı fonksiyon `RETURNS TABLE(merkle_root, row_count, max_id)` döndürüyor; G-2 "yalnız sayı, toplam veya boolean" diyordu | **Kural eksik yazılmıştı, metin düzeltildi.** G-2 dönüş *tiplerini* sayıyor ama korumak istediği kuralı söylemiyordu; kontenjan sayacı için yazılmış, digest sınıfı öngörülmemişti. Yeni metin kuralı yazar ve bunu yaparken **daraltır**: "kapsam dışı satır **içeriği** dönmez" yasağı ilk kez metne girdi. Üç değerin tek taramadan gelmesi zorunluluğu da yazıldı — ayrı çağrılarda araya giren INSERT, sınırları kapsadığı kümeyi tarif etmeyen bir mühür üretirdi |
| **F-5c** — `app.roll_up_usage_daily` **yazan** bir SECURITY DEFINER; COMMENT'i G-1'e dayandığını söylüyordu | **Yeni sınıf tanımlandı, G genişletilmedi.** G baştan sona *okuma* hakkındadır; yazan fonksiyon §2.7'nin A sözleşmesi gibi kendi kurallarını taşımalıdır. chunk2 **§2.6.1 — W sözleşmesi** (W-1…W-6) eklendi: yalnız kendi türev tablosuna yazar, iş verisine **hiçbir gerekçeyle** yazamaz, tablolar gövdede, parametre yalnız aralık, çıktı kişi kırılımı taşımaz, S-9 envanterine **ayrı sınıf** olarak girer. Yanlış COMMENT düzeltildi |
| **F-13** — kriter 11b chunk5/chunk6 olmadan sınanamıyordu | **Taşıma değil tekilleştirme çıktı:** aynı kriter chunk6 §6.9'da **7d** olarak zaten yazılıydı, yani 11b baştan beri çift kayıttı. chunk3'ten düşürüldü, 7d "tek yer" olarak işaretlendi. Emsal: P-4 |

**Bu turda ayrıca bulunan (heyet dışı, F-5 düzeltilirken çıktı):** `audit_batch_merkle_root` yaprak satırında `created_at` metne çevrilirken **oturumun TimeZone ve DateStyle ayarına** bağlıydı. Canlı ortamda doğrulandı — aynı satır üç ayarda üç farklı yaprak üretti (`2026-07-26 02:55:20.188807+00` · `2026-07-26 05:55:20+03` · `26.07.2026 05:55:20 +03`). Dışarıdan doğrulayan bilirkişi kendi oturum ayarıyla farklı kök hesaplar ve zinciri **bozuk görürdü** — bağımsız doğrulanabilirlik iddiasının tam merkezinde bir kusur. Yaprak kuralı `app.audit_leaf_line()` adlı ayrı fonksiyona alındı ve zaman `to_char(... AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US')` ile sabitlendi (üç ayarda aynı çıktı, canlı doğrulandı). Kural adı mühürde taşınıyor: `hash_algorithm` artık `sha256` değil **`sha256-utc-iso-v1`** — kuralları kendinden uzun yaşayan bir zincir, her halkanın hangi kural altında üretildiğini söylemek zorundadır.

**2.4.5 — 26.07.2026 (heyet review 135 — ❌ REJECTED, 15 bulgu).** Heyet 134'ün 13 bulgusunun 13'ünü de bağımsız olarak kapalı doğruladı; red **yeni bir bölgeden** geldi ve haklıydı: *mühür üretimi sağlam, mührün bekçiliği değil.* Dört bulgu aynı yerde birleşiyordu.

| Bulgu | Yapılan |
|---|---|
| **F-1** (ağır) Merkle yaprağı yalnız `(id, created_at, tenant_id)` kapsıyordu; AD-06'nın "tek satır değişse kök değişir" iddiası **zarf için doğru, içerik için yanlıştı**. Trigger'ı kapatabilen biri `new_values`/`user_id`/`event` değiştirip geri açtığında kök, zincir, tanık ve doğrulama işi **hepsi temiz** kalıyordu | **Yaprak v2** — satırın **her kolonunu** kapsıyor. Alanlar **uzunluk önekiyle** kodlanıyor (`app.audit_leaf_part`): serbest metin alanlarda ayırıcı belirsizliği iki farklı satırı aynı yaprağa düşürebilirdi. Kural adı `sha256-utc-iso-v2`; v1 halkalar v1 olarak kalıyor — versiyonlama tam da bunun içindi. **Kolon kümesi teste sabitlendi** (kriter 29): delil tablosuna kolon eklemek testi kırıyor, yani aynı kusur arka kapıdan geri gelemiyor. Sets kararı: kapsamı genişlet |
| **F-2** `VerifyAuditChain` kökü satırlarla **hiç karşılaştırmıyordu** — fonksiyonu çağırıp yalnız satır sayısını kullanıyordu; üstelik `p_to_id` olmadığı için tarihsel partinin kökü yapısal olarak hesaplanamıyordu | `app.audit_batch_merkle_root`'a **üst sınır** parametresi eklendi; iş artık her mühür için kökü yeniden hesaplayıp saklananla karşılaştırıyor. Ayrıca `hash_algorithm` **okunuyor**: tanımadığı kuralla mühürlenmiş halkayı yanlış kuralla karşılaştırmak yerine raporluyor (kural adı yazılıp okunmazsa dekorasyondur). Kriter 30, 31 |
| **F-3** `published()` mevcut klonu `fetch` etmiyordu; tanık karşılaştırması **uygulamanın kendi yazabildiği** kopyaya karşı yapılıyordu — docblock tam tersini iddia ediyordu | Her okumada `fetch` + `reset --hard`. Test: yerel klondaki dosyayı elle bozmak karşılaştırmayı yanıltmıyor. Kriter 32 |
| **F-4** `compareWithWitness`'in iki bacağı **hiç koşmamıştı** (iki tanık double'ı da boş liste dönüyordu) | `RecordingWitness` double'ı yazıldı; iki yön de sınanıyor (tanıkta olup bizde olmayan / bizde olup tanıkta olmayan). Kriter 27 artık gerçekten kapalı |
| **F-5** `witnessed_at`'e **bizim saatimiz** yazılıyordu; `WitnessReceipt` sözleşmesi bunu açıkça yasaklıyordu | `WitnessReceipt::recordedAt` nullable oldu, git tanığı **null** dönüyor. Yayımlanmışlığın işareti `witness_reference`; `PublishAuditRoots` telafi taraması buna göre. Ayrıca `alreadyPublished` yolunda referans olarak HEAD değil, hash'i **getiren commit** dönüyor. Kriter 33 |
| **F-6** G-2'nin yeni "kriptografik digest" kategorisi **niteliksizdi** — tek alanın digest'i TCKN gibi küçük uzayda tahmin-doğrulama oracle'ı olur | G-2'ye iki koşul: digest **kümenin** özetidir ve çağıran **sınırları serbestçe seçemez**. Küme tek satıra düşebiliyorsa (mühür partisi) bu **kabul edilmiş artık risktir** ve gerekçesi AD-06'ya yazıldı — şeffaflık günlüklerinin ortak özelliği, tuzlamayla kapatılamaz (tuz yayımlanmazsa bağımsız doğrulama biter) |
| **F-7** §3.9'da eski "continuous aggregate" satırı silinmemişti | Düzeltildi |
| **F-8** Spec §7.1 içeriği değişmiş, sürüm damgası bir tur geride kalmıştı | Spec **v1.10** + revizyon girdisi; R4-1 kuralı spec'e de uygulandı |
| **F-9** (a) `TenantContext::run(Closure)` semantiği chunk2 §2.5'e işlenmemişti (b) issues.md tanık kalemi kodun gerisindeydi | §2.5'e iki satırlık kesin tablo; issues.md güncellendi |
| **F-10** Kriter 5'in kanıtı incelmişti — yeni test yalnız mührün oluşmadığını assert ediyordu | Testte iş/audit kaydının **ayakta kaldığı** yeniden assert ediliyor |
| Açık soru 3 (orphan feed'ler) | **Bilinçli kabul, tutanağa geçti** (Sets kararı): `docs/issues.md` kalemi + chunk3 kriter 34 + chunk6 §6.9 madde 16 + chunk8 §8.9 madde 16. Kabul kalıcı muafiyet değil **erteleme** — kapanışı o iki maddeye bağlı |

**Sonuç:** 237 test (91 + 146), PHPStan 0, Pint temiz. Yeni kriterler 28–34.

**2.4.6 — 26.07.2026 (heyet review 136 — ✅ APPROVED, 3 minör).** Verdict onaydır: 15/15 kapanış bağımsız doğrulandı, yeni kriterler 28–34 gerçek testlerle karşılandı, CI #715 (238 test) review sırasında yeniden üretildi. Üç minör bulgu yeni tur gerektirmeden kapatıldı:

| Bulgu | Yapılan |
|---|---|
| **F-r3-1** — G-2(b) "çağıran sınırları serbestçe seçemez" der ama `audit_batch_merkle_root(text, from, to)` imzası bunu zorlayamaz; fiilî zorlamanın adresi yazılı değildi | G-2'ye ibare: zorlama **chunk9 S-12 allowlist taraması** + **G-5 kod incelemesi** kapılarındadır; imzadan "sınır kontrolü fonksiyonda olmalıydı" sonucu çıkarılmamalıdır. Bilinçli olduğu adlandırıldı |
| **F-r3-2** — migration yorumu "v1 halkalar v1 olarak doğrulanır" diyordu; oysa yalnız güncel kural hesaplanabiliyor, eski kurallı mühür süresiz "cannot recompute" üretirdi | Yorum sistemin **gerçekten yaptığı** şeye çekildi. Çok-kurallı doğrulama `docs/issues.md`'ye kalem — karar ilk gerçek kural değişiminde, iki seçenekle (eski kural fonksiyonlarını sürüm adıyla tutmak / belirli tarihten eski halkalar için "doğrulanamaz ama dokunulmamış" statüsü) |
| **F-r3-3** — yaprak kuralının `jsonb` bileşeni repo içinde tanımsızdı; PostgreSQL jsonb serileştirmesi sürümler arası taahhüt değil | Kanonik biçim (anahtar sıralaması, tekilleştirme, boşluk, sayı gösterimi) README'ye yazıldı; PG major yükseltmesinin kural sürümü tetikleyebileceği `docs/issues.md`'ye kalem |
| **Açık soru 1** — anchor README repo dışıydı, heyet doğrulayamamıştı | Kural **repoya alındı**: `docs/arch/tanik-deposu-readme.md` tek kaynaktır, tanık deposundaki kopya yayımlanmış hâlidir, ikisi + `app.audit_leaf_line` **aynı commit'te** değişir. README'de v1'den kalan iki çelişki de düzeltildi (`hash_algorithm` örneği hâlâ v1 diyordu; NULL kuralı "boş dize" diyordu — v2'de `-;`) |

**Bağımsız doğrulama fiilen yapıldı (26.07.2026).** README'de yazan kuralın gerçekten uygulanan kural olduğu, **uygulama kodu hiç kullanılmadan** kanıtlandı: `psql` ile ham satırlar çekildi, kök Python'da yeniden hesaplandı (uzunluk önekli yaprak + ikişerli katlama + tek düğümün kopyalanmadan taşınması) ve `app.audit_batch_merkle_root`'un ürettiği kökle **birebir eşleşti** (`923fcbff…682e`). Test verisi bilinçli olarak zorlayıcı seçildi: NULL'lar, boş dize, Türkçe karakter, yaprak ayırıcılarının (`|`, `;`, `:`) içerikte geçtiği alanlar, jsonb (anahtar sırası bozuk yazılıp kanonikleştirilen), IPv6. `docs/issues.md`'deki "bağımsız doğrulama aracı" kalemi **prosedür** tarafında kapandı; paketlenmiş araç ayrı kalem olarak duruyor.

**Ayrıca bulundu (bu doğrulama sırasında):** audit zaman damgaları `precision=0` — Laravel `timestampsTz()` varsayılanı, bilinçli karar değil. Zincir etkilenmiyor (yaprak sırası `id` bazlı) ama kesirli kısım bilgi taşımıyor; değiştirmek yaprak kuralını `v3`'e çıkarır. `docs/issues.md`'ye kalem, karar Sets'in.

**2.5 — 26.07.2026 (altyapı sürüm yükseltmesi — Sets kararı).** Sets "son sürüm" tercihini baştan söylemişti; PostgreSQL 17 ve Redis 7 bu tercihe rağmen sorgusuz kalmıştı. Üç karar alındı ve uygulandı.

| Karar | Yapılan |
|---|---|
| **PostgreSQL 17.10 → 18.4** | Özel imaj `timescale/timescaledb-ha:pg18.4-ts2.28.3` tabanına alındı. TimescaleDB sürümü **değişmedi** (2.28.3) — taşınan yalnız PostgreSQL major'u. Anonymizer 1.3.2 → **3.1.3**: 2.x ile proje C'den pgrx (Rust)'a geçti, 1.3 serisi PG18'i tanımıyor; PGDG arm64/jammy için hazır paket sunmuyor (doğrulandı) → kaynaktan derleme zorunluluk. **İki aşamalı build zorunlu oldu:** tek aşamada derleyip `apt purge` + `rm` yapmak imajı küçültmüyor (silinenler önceki katmanlarda kalır) — ilk deneme 11.8 GB çıktı. Builder aşaması + sade taban ile **2.09 GB** (PG17'nin 4 GB'ından küçük). |
| **Redis 7.4.7 → 8.8.1** | `redis:7-alpine` → `redis:8.8.1`. Topoloji değişmedi: 1 master + 2 replica + 3 Sentinel, quorum 2, hepsi doğrulandı. |
| **Audit damga hassasiyeti** | `precision=0` → **`precision=6`** (dört audit tablosu). Framework varsayılanıydı, karar değildi. Yaprak kuralı **değişmedi** — `to_char(... '.US')` zaten mikrosaniye render ediyordu, kesirli kısım artık bilgi taşıyor. Şema testiyle sabitlendi. Üretimde halka olmadığı için bedelsizdi. |

**chunk2 §2.1 deneyleri PG 18.4'te yeniden koşuldu — sapma yok.** Altı ortam deneyi ve chunk3'ün iki sapması birebir aynı davrandı: superuser hâlâ `FORCE RLS`'i bypass ediyor, `SET LOCAL` hâlâ boş string bırakıyor, politikasız UPDATE hâlâ sessiz, iki permissive politikanın ikisi de değerlendiriliyor, columnstore ve continuous aggregate hâlâ RLS'li hypertable'da kurulamıyor. Yani chunk3'ün rollup + W sözleşmesi çözümü geçerliliğini koruyor.

⚠ **Yükseltme sırasında, volume yenilendiği için, TEMIZ KURULUMDA iki sağlama kusuru ortaya çıktı.** İkisi de mevcut dev volume'u tarafından maskeleniyordu ve ilk gerçek belirtisini **production kurulumunda** verecekti — ayrıntı ve ders `docs/issues.md`'de, çıkış kalemleri EGT-51 [7] ve [8]'de:

1. **Uygulama rolü superuser doğuyordu** (kırmızı çizgi 1). Compose cluster'ı `POSTGRES_USER: ${DB_USERNAME}` ile bootstrap ediyordu; `initdb` o rolü superuser yaratır. Düzeltildi: bootstrap yönetici rolüyle. `DatabaseRoleTest`'in ilgili testi zaten vardı ve **doğruydu** — kusur testte değil ortam sağlamasındaydı.
2. **`pgcrypto` hiçbir migration'da yoktu.** Zincir `digest()` kullanıyor; uzantı yalnız container init script'indeydi. Temiz veritabanında 28 test düştü ve production'da **hiçbir mühür yazılamazdı**. Merkle migration'ına eklendi.
3. **Paralel rol yaratma yarışı yalnız `duplicate_object` yakalıyordu.** Roller cluster genelinde; sekiz test worker'ı aynı anda `CREATE ROLE` denediğinde kaybeden oturum `unique_violation` (23505) alabiliyor. Yarış ancak roller **gerçekten yokken** kaybedilir — cluster ömründe tam bir kez, temiz kurulumda. `OR unique_violation` eklendi; yerel cluster sıfırdan kurulup paralel paket koşularak doğrulandı.

**Üçünün ortak dersi ve yeni kural:** üçü de *mevcut ortamın kodun eksiğini taşıması* kalıbından. Tetikleyici hep "ilk kez" olmak — ilk bootstrap, ilk uzantı, ilk rol. Bunlar production'da tam bir kez yaşanır. **Altyapıya dokunan her değişiklikten sonra veritabanı sıfırdan kurulup tam paket koşulur;** "mevcut ortamda çalışıyor" kanıt sayılmaz.

**2.6 — 26.07.2026 (üç kimlik alanı — spec v1.11'in plan katmanına işlenmesi).** Spec v1.11 heyet re-review'undan APPROVED çıktı (`docs/reviews/review-omurga-spec-2026-07-26-r2.md`); bu revizyon, heyetin **plan turuna devrettiği** senkron listesini kapatıyor. Kararın kendisi tartışılmadı — plan katmanı üçüncü alana uyarlandı.

| Değişen | Yapılan |
|---|---|
| **chunk5 §5.1** | Üç tablo ailesi tamamlandı: her alanın **kendi şifre sıfırlama tablosu** var. Müşteri tarafı `customer_user_id` anahtarlı — e-posta anahtarlı bir tablo hangi tenant'ın kaydının sıfırlandığını söyleyemezdi (spec §3.2). I-5 (kayıtlar bağlanmaz) plan tarafına kriter olarak indi |
| **chunk5 §5.1.1 (yeni)** | Üç guard, üç oturum tablosu. Laravel'in tek `sessions` tablosu **düşürülüyor**: veritabanı oturum sürücüsü satırı `user_id` ile bağlar ve üç alanın id'leri çakışır — A-2 ("kendi oturumlarını gör ve sonlandır") bu çakışmayla doğru çalışamazdı. Reddedilen alternatif (tek tablo + `identity_domain` kolonu) gerekçesiyle yazıldı |
| **chunk5 §5.1.2 (yeni)** | Üç şifre sıfırlama akışı. Müşteri akışının tenant belirsizliği **yeni bir bağlamsız kapı açmadan** çözüldü: mevcut `app.authenticate_lookup` kullanılır, e-posta birden çok tenant'ta varsa her kayıt için ayrı token üretilir. Gerekçe spec §4.1 B-3: bağlamsız yüzeyi genişletmek, döndürülen alanı daraltmaktan pahalıdır |
| **chunk5 §5.3, §5.6** | Yöntem matrisi ve oturum süresi **üç alanda** bağımsız; backoffice varsayılanları eklendi |
| **chunk5 §5.7** | MFA ikiye ayrıldı: **(a) yetki bazlı** (ofis/müşteri, zorlaması chunk6'da) ve **(b) alan bazlı** (backoffice'in tamamı, zorlaması **bu chunk'ta**, testi bu chunk'ın kriteri). Ayrım spec §5.2'nin iki ayrı hükmünün karşılığı |
| **chunk5 §5.8** | Host → kimlik alanı → guard → oturum tablosu **tek tabloda**; alan tespiti tek middleware'de (`ResolveIdentityDomain`). Filament yalnız `backoffice` guard'ıyla çalışır ve `users`'a hiç bakmaz |
| **chunk5 kriterleri** | 19 yeniden yazıldı; **20–26** eklendi (spec kriter 31–37'nin bu chunk'a düşen yarısı) |
| **chunk6 §6.1, §6.2** | "İki ayrı yetki dünyası" → **üç**. Backoffice izin ailesi (`backoffice_permissions` vd.) tanımlandı. `system.access_backoffice` **kaldırıldı** — eski iki-alan modelinin kapısıydı. `user.provision` / `user.assign_permissions` backoffice ailesine kondu; aynı adın iki ailede iki anlamı olduğu ve çakışmadığı yazıldı. Kriter 2 üçe çıkarıldı, **2a** ve **2b** eklendi |
| **chunk6 §6.5** | `SimpleScopeResolver`: backoffice kimliği çözümleyiciye girmez, boş dizi döner. ⚠ Bunun bir **yetki tavanı olmadığı** (spec §3.3) açıkça yazıldı — yol bugün üretilmemiştir, kapalı değildir |
| **chunk2 §2.8** | Bağlamsız kesin listeye backoffice şifre sıfırlama tablosu ve **backoffice izin ailesi** eklendi; müşteri izin ailesinin listede **olmadığı** (tenant-kapsamlı, RLS'li) açıkça yazıldı. `sessions` satırına chunk5'te düşürüleceği notu. `config/tenancy.php` aynı turda senkronlandı |
| **chunk3 §3.6.1** | `audit.view_all_tenants` artık **backoffice izin ailesinde**; "`system.access_backoffice` tek başına yetmez" cümlesi kaldırılan izne atıf yapıyordu |
| **chunk9 §9.5** | **S-18** eklendi (Sets kararı — heyet 139): `config('crypto.encrypted_columns')` listesi koddan türetilenle **iki yönlü** karşılaştırılır; koddaki kolon listede yoksa **veya** listede olup kodda yoksa build kırılır. S-16 bu listeye güvenir, doğruluğunu S-18 garanti eder. Emsal: `config/audit.php` `getTable()` desync kontrolü |
| **`docs/issues.md`** | "Ayrı `backoffice_users` tablosu önerisi" kalemi **kapandı**; kapanış notunda eski kalemin artık yanlış olan yanı (\`system.access_backoffice\` "bugünkü sınır") düzeltildi |

**2.7 — 27.07.2026 (chunk5 implementasyonu — planla gerçeğin karşılaştığı yerler).** Implementasyon plan revizyonunu doğruladı; aşağıdakiler kod yazılırken **ortaya çıkan** ve planda karşılığı olmayan kalemlerdir. Hepsi ilgili chunk belgesine işlendi.

| Bulgu | Yapılan |
|---|---|
| **Tenant'ın kendisini yaratmak** planda yoktu. `customers` kendi `id`'siyle RLS'li olduğu için yazılan satır kapsamın kendisidir | `TenantContextGuard`'a **üçüncü istisna** (bileşene verilir, yalnız `INSERT` + yalnız `customers`) ve **`RETURNING` kullanılamaz**: PostgreSQL `INSERT … RETURNING`'de SELECT politikasını da uyguluyor — canlı doğrulandı, `new row violates row-level security policy`. Id sequence'ten alınıp INSERT `RETURNING`'siz yazılıyor. Reddedilen iki alternatif (SELECT politikasını gevşetmek / `SECURITY DEFINER` yazıcı — W sözleşmesi yasağı) gerekçeleriyle kodda |
| **`SessionGuard::login()` olayını `setUser()`'dan ÖNCE fırlatıyor** (framework kaynağından doğrulandı) | Dinleyen audit yazıcısı "kim giriş yaptı" diye sorunca guard kullanıcıyı **veritabanından** yeniden okuyor — RLS'li `customer_users`'tan, kapsamsız. Giriş `TenantContext::run([$tenantId])` içine alındı. İki kazanç: sorgu geçiyor **ve** audit satırı doğru tenant'ı taşıyor (kapsamsız yazılsaydı `tenant_id` NULL olurdu, o da "ofis olayı" demektir — chunk3 §3.6) |
| **`ActorType` üçüncü kimlik alanını tanımıyordu** | `backoffice_user` eklendi ve `visibleToOffice()`'e **konmadı**. Eklenmeseydi backoffice eylemleri `system`'e düşerdi — ve `system` ofis feed'inde görünür; §7.5'in izin vermediği şey. Kolon `string(32)`, şema değişmedi, yaprak kuralı etkilenmedi |
| **Varsayılan guard'ın kaldırılması framework'ün "current user" yolunu kırdı** | `Auth::resolveUsersUsing()` ile isteğin kimlik alanına bağlandı. Kırılan yerler: `SetLocale`, `EnsureTenantContext`, `Gate::authorize` (yani `BackofficeAuditFeed`). Adlandırılmış guard'lar zaten çalışıyordu; bağlanan yalnız **niteliksiz** soru |
| **Şifre sıfırlamayı tüketen taraf üçüncü bir dar kapı gerektirdi** | `app.password_reset_lookup(text)`, §2.7'nin A sözleşmesinde, beş alan. Alternatif "tenant id'yi URL'ye koymak" reddedildi: kimliksiz isteğin kendi kapsamını seçmesi §2.5'in önlemek için var olduğu şey |
| **Magic link'in yalnız tüketen tarafı yazılmıştı** | `RequestMagicLink` + `POST /baglanti` eklendi; şifre sıfırlamayla aynı şekil ve ortak hız sınırı (`AnonymousRequestLimiter`). Yalnız tüketici yazmak, ayarda açılabilen ama ulaşılamayan bir yöntem bırakıyordu. chunk5 kriter **27** |
| **Oturum sürücüsü `redis`'ti** | `domain`'e çevrildi (`.env.example`, `phpunit.xml`). A-2 ("kendi oturumlarımı gör ve sonlandır") anahtar-değer deposunda ikinci bir indeks olmadan cevaplanamaz. ⚠ Test ortamı da **gerçek sürücüyü** koşuyor: `array` sürücüsü üç oturum tablosunu da hiç sınamıyordu |
| **`users.email` tipi** | Sets onayıyla `citext` (üç tabloda da). Garanti veritabanında; uygulamada küçük harfe çevirmek, başka bir yoldan gelen kaydı yakalamaz |
| **chunk5 kriter 8** (yetki değişikliği olayı) | **chunk6 §6.9'a taşındı**: "yetki değişikliği" izin altyapısı olmadan gerçekleşemez, ve o altyapı chunk6'nın. Burada yazmak tüketicisi olmayan sembol üretmek olurdu. Emsal: kriter 7 ve 11 |
| **`CreateCustomer` orphan** | Bilinçli kabul edildi ve kapanışı **chunk6 kriter 15a**'ya bağlandı (emsal: chunk3'ün `OfficeAuditFeed`'i). Müşteri açma ekranı backoffice/ofis işidir |
| **chunk5 §5.9 D-2'nin ikinci yarısı geçersiz** | `app.all_customer_ids()` hiç var olmadı: chunk3 §3.6.1 o yaklaşımı zaten reddetmişti (kendi yasağı). Üretilmedi — tüketicisi olmayan sembol üretilmez |

**Sonuç:** 327 test (102 + 225), PHPStan 0, Pint temiz.

**2.8 — 27.07.2026 (chunk6 planı: üç izin ailesi ve yetki zorlama; heyet 147'nin 13 bulgusu).** Chunk6'nın planı spec v1.11 dalgasında revize edilmiş ama hiç review görmemişti; bu tur onu kapattı.

| Bulgu | Yapılan |
|---|---|
| **F-1** §6.5'in düzeltmesi yarım kalmıştı — P-7 gerekçe bloğu hâlâ *"geçici çözüm ofis kimliğine tüm tenant'ları verir"* diyordu | Gerekçe yeniden yazıldı: kısıt artık *"yanlış kapsamla çıkmayalım"* değil **"kapsam yok"**. Ofis tarafında müşteri verisine dokunan modül sessizce yanlış davranmaz, ilk sorguda düşer |
| **F-2** `docs/issues.md` *"Chunk6 TreeScopeResolver ile kapanır"* diyordu | Düzeltildi: `TreeScopeResolver` **Spec 3'ün ürünüdür**, chunk6 onu üretmez |
| **F-3/F-4** kriter 15a'nın zemini (izin, panel, audit) tanımsızdı | **Kriter düşürüldü** (Sets kararı) ve devir tablosuna adresiyle yazıldı. Panel kararı şimdiden çivilendi: **ofis** |
| **F-5** `ScopeResolver` arayüzü planda iki metotlu, kodda tek | `scopeLevel` bu chunk'ta **eklenmiyor**: kapsam seviyesi organizasyon ağacının kavramı ve `SessionScopeResolver`'ın verebileceği tek dürüst cevap `null` olurdu — arayüzü genişletmenin en kötü hâli |
| **F-6** üç ailenin **işletme** mekaniği hiç yazılmamıştı | §6.1.1 (A-1…A-4): aile başına `Gate` bağlaması · müşteri izin sorgusunun tenant kapsamı içinde koşması (AD-04) · **izin cache'inin tenant başına anahtarlanması** (spatie global tutuyor — ayrı tablo tek başına yetmez, izolasyon uygulama belleğinde delinir) · tenant'ın rol tanımlarının **tenant doğarken** doğması (global seed RLS'li tabloda sonraki tenant'ları kapsayamaz). Kriter 2d ve 2e |
| **F-7/F-9 + açık soru 1, 4** kriterlerin şart koştuğu yüzeyler plan gövdesinde yoktu | §6.5.1: **backoffice** ve **müşteri** yüzeyleri bu chunk'ta doğar (ikisinin de kapsamı çalışıyor), **ofis** yüzeyi doğamaz. Backoffice kullanıcı yönetimi kaynağı kriter 2b'ye yüzey verir ve `backoffice_user.*` + `user.provision` izinlerine tüketici olur — plan index'in kapsam sınırı *"backoffice'in omurga ekranları"nı* zaten istisna sayıyor, yeni istisna açılmadı |
| **F-8** backoffice yetki değişikliğinin audit'i planlanmamıştı | `BackofficeUserPermissionsChanged` eklendi. Audit'siz kritik işlem kırmızı çizgi 13'tür; iki olay tanımlayıp üçüncü alanı atlamak, denetimi **en geniş yetkili** alanda eksik bırakmak olurdu |
| **F-10** R-1 alfabesi kendi kuralını ihlal ediyordu — `L` içeriyordu | Alfabe düzeltildi (`23456789BCDFGHJKMNPQRSTVWXYZ`) ve kriter 12 **kurala karşı** doğrulayacak biçimde yazıldı; dizgeyi kopyalayan bir test hatayı kalıcılaştırırdı |
| **F-11** ofis ailesinde `audit.view_access_log` vardı | Kaldırıldı: spec §7.5 erişim logunu ofiste **"Görünmez"** yazar; izin varsa er ya da geç birine verilir. Backoffice ailesine taşındı |
| **F-12** index sürüm damgası ve upstream kilidi eskiydi (üçüncü kez) | Sürüm 2.8, upstream **v1.11**; ayrıca iki satırın her revizyonda güncelleneceği uyarısı yazıldı |
| **F-13** yüzeysüz kurallar ve yeni orphan'lar adressizdi | Devir tablosuna dört satır: kriter 16, kriter 32'nin ofis bacağı, `CreateCustomer` + müşteri açma ekranı, V-3/R-7'nin yüzeyleri |
