# Review — Omurga Chunk 4 (EGT-49) İmplementasyonu — 2026-07-26

> **Review type:** impl-review
> **Previous review:** N/A (chunk4'ün ilk impl review'u; emsaller: `review-omurga-chunk1-impl-2026-07-26.md`, heyet 127/128 (chunk2), heyet 134–136 (chunk3, APPROVED))
> **Upstream document(s) read:** `docs/plans/omurga-plan_chunk4.md` (plan v2.5), `docs/plans/omurga-plan.md` (index), `docs/specs/omurga-spec.md` v1.10 §3.2, §7.2, §8.1–8.5, §12, §13
> **Other cross-referenced documents:** `CLAUDE.md` (17 kırmızı çizgi, dil kuralı), `docs/plans/omurga-plan_chunk5.md` (§5.9 D-4 — devir hedefi), `docs/plans/omurga-plan_chunk6.md` (§6.4, §6.9 madde 17 — devir hedefi), `docs/plans/omurga-plan_chunk9.md` (§9.5 taramaları), `docs/issues.md` (rotasyon kalemi :130, orphan tutanağı :87)
> **Stage locks in effect:** Spec v1.10 + Plan v2.5 locked

**Bağımsız olarak yeniden üretilen kanıt (bu review sırasında, bu makinede):**

- Tam paket: **265/265 test geçti** (805 assertion) — CI #727 iddiası bağımsız doğrulandı.
- `--group=omurga-rls`: 170/170 geçti.
- Pint: chunk4 dosyalarında temiz (`pint --test` passed).
- **Kriter 12 uçtan uca bağımsız doğrulandı:** canlı DB'ye PII probe satırı yazıldı (`heyet-probe@example.com`), dump alındı, `anonymize-dump.sh` koşuldu — probe kaynak dump'ta 1, maskeli dump'ta **0**; "12 kolon maskelendi" çıktısı üretildi; `APP_ENV=production` ile koşum **reddedildi**. Probe satırı silindi.
- Kırmızı çizgi taraması: `env()` config dışında yok; hardcoded credential yok; commit'lerde AI attribution yok; `consent_texts` hem `config/tenancy.php:93` (exempt) hem `config/locales.php:36` (translatable) kayıtlı; test disiplini (grup etiketi, `Date::now`, `Http::preventStrayRequests` — `tests/Pest.php:177`) yerinde.

---

## Upstream Coverage Matrix

| Upstream § / madde | Reviewed doc'ta durumu | Kanıt |
|---|---|---|
| §4.1 aranabilir hash mekanizması (HMAC, tenant anahtarlı, normalizasyon) | ✓ | `TenantKeyDeriver.php:110-142`; testler `TenantKeyTest.php:53-71` |
| §4.1 alan şifreleme mekanizması (cast) | ⚠ | `TenantEncrypted.php` mevcut ama **hiçbir fonksiyonel testi yok**; hiçbir kolon kullanmıyor → **F-3** |
| §4.1 unique yalnız `(tenant_id, national_id_hash)` | ✓ (devir) | chunk5 §5.9 D-4 (`omurga-plan_chunk5.md:139`) birebir aynı kriter — tekilleştirme doğrulandı |
| §4.2 HKDF-SHA256, salt=tenant, info=amaç; tanınmayan amaç exception | ✓ | `TenantKeyDeriver.php:77-95`; test `TenantKeyTest.php:46-51` |
| §4.2 kök anahtar `TENANT_ROOT_KEY`, `APP_KEY` değil, yalnız env | ✓ | `config/security.php:81-84`; test `TenantKeyTest.php:73-81`; kısa/boş anahtar reddi `TenantKeyDeriver.php:144-177` |
| §4.2 `key_version` şifreli değerlerin yanında; rotasyon yapılmadı, issues.md kalemi | ⚠ | Dosyalar için ✓ (`document_versions.key_version`, migration:192; `SecureFileStore.php:66`); **alan şifrelemesi için mekanizma yok** — cast yalnız şifreli değeri yazar → **F-3**. Rotasyon kalemi ✓ `docs/issues.md:130` |
| §4.3 `is_special_category` bayrağı kayda kopyalı (tanımdan okunmuyor) | ✓ | migration:125-139; `Document.php:19-21`; izin yarısı chunk6 §6.9/17'ye devir doğrulandı (`omurga-plan_chunk6.md:154`) |
| §4.4 D-1 içerik depoya yazılmadan önce tenant anahtarıyla şifreli | ✓ | `SecureFileStore.php:48-68`; test `SensitiveDataTest.php:42-60` |
| §4.4 D-2 süreli + tekil bağlantı; kalıcı genel URL yok; bucket kapalı | ⚠ | TTL+tek kullanım kurulu (`SecureFileStore.php:108-156`); bucket private ✓ (`config/filesystems.php` visibility + test :238-260). **Tek kullanımlık garanti atomik değil → F-6; süre dolumu testi yok → F-7** |
| §4.4 D-3 bağlantı üretilirken **yetki + tenant kontrolü** + erişim logu; kullanıcıya bağlı | ⚠ | Erişim logu ✓ (`SecureFileStore.php:110-113`, test :129-149); kullanıcı bağlılığı ✓ (test :113-127). **Yetki + tenant kontrolü ne kodda ne devir kaydında → F-4** |
| §4.4 D-4 SHA-256 özet + sürümlü saklama; kritik belge silinmez | ⚠ | Özet ✓ (düz metin üzerinden, migration:182-185; test :76-91); `documents` DELETE trigger'ı ✓ (migration:216-240, test :151-158). **`document_versions` satırları silinebilir → F-2** |
| §4.4 tablolar tenant-kapsamlı, RLS'li, `Auditable` | ✓ | migration:158, 201 (`TenantPolicy::apply`); `Document.php:23-26`, `DocumentVersion.php:21-23` |
| §4.5 `consents` tenant-kapsamlı, `Auditable`, **silinmez** | ❌ | Tenant+Auditable ✓ (migration:109, `Consent.php:22-25`). **"Silinmez" karşılıksız: standart DELETE policy silmeye izin veriyor, trigger yok → F-1** |
| §4.5 `consents.locale` NOT NULL, dilsiz kayıt reddi | ✓ | migration:94; test `SensitiveDataTest.php:185-204` |
| §4.5 `consent_texts` sürümlü, çevrilebilir jsonb, bağlamsız; fallback | ✓ | migration:48-72; `ConsentText.php`; fallback testi :224-236 |
| §4.6 anonimleştirme scripti + kurallar; prod reddi kaçamaksız | ✓ | `anonymize-dump.sh:38-46` (ALLOW_PRODUCTION_ANONYMIZE bilinçli desteklenmez); **bu review'da uçtan uca bağımsız doğrulandı** (yukarıda). Dil kuralı ihlali ayrı → **F-8**; `storage_path` maskesiz → **F-5** ile kesişiyor |
| §4.7 üçüncü taraf istemci analitiği yok | ✓ | `tests/Arch/SensitiveDataTest.php:44-74` |
| Kriter 1 — hassas alanlar veritabanında şifreli | ❌ | Sınanamaz: hassas alan taşıyan tablo chunk5'te doğuyor; kriter, kriter 3'ün aksine **devredilmemiş/işaretlenmemiş** → **F-3** |
| Kriter 2 — aynı TCKN iki tenant'ta farklı hash (test) | ✓ | `TenantKeyTest.php:53-62` |
| Kriter 3 — ~~global unique~~ → chunk5 D-4 | ✓ (devir) | chunk5:139, aynı kriter — tekilleştirme meşru (heyet 134 F-13 emsali) |
| Kriter 4 — kök anahtar bağımsız; `key_version` yanında | ⚠ | Kök ✓ (test :73-81); `key_version` yalnız dosya tarafında → **F-3** |
| Kriter 5 — bayrak mevcut + belge erişimi erişim logu (test); izin yarısı chunk6'ya devir | ✓ | migration:139; test :129-149; devir hedefi chunk6:154 açık atıfla mevcut |
| Kriter 6 — içerik depoya şifreli; bucket public kapalı | ✓ | test :42-60; test :238-246 + `config/filesystems.php` visibility 'private' |
| Kriter 7 — süreli + kullanıcıya bağlı; **süresi dolmuş** veya başka kullanıcının bağlantısı reddediliyor (test) | ⚠ | Kullanıcı bağlılığı + tek kullanım testli (:93-127); **süre dolumu senaryosunun testi yok → F-7**; eşzamanlılıkta tek kullanım garantisi → **F-6** |
| Kriter 8 — bağlantı üretimi erişim logu yazıyor (test) | ✓ | test :129-149 (`audit_access`, `document.download`) |
| Kriter 9 — kritik belge silme exception (SQL denemesiyle) | ✓ | test :151-158 (ham DELETE, `QueryException`); dar trigger kanıtı :160-168 |
| Kriter 10 — dilsiz rıza reddi (test) | ✓ | test :185-204 |
| Kriter 11 — `consent_texts.body` çevrilebilir, fallback (test) | ✓ | test :224-236 |
| Kriter 12 — maskeli dump + prod reddi | ✓ | Reviewer tarafından uçtan uca yeniden üretildi (yukarıda); arch test :103-116 |
| Kriter 13 — üçüncü taraf istemci analitiği yok (kod taraması) | ✓ | arch test :44-74 |
| Kriter 14 — süre dolumunda silme/anonimleştirme işi yok | ✓ | arch test :76-101 |

---

## ✅ What works

- **Anahtar türetme tasarımı sağlam ve gerekçeli.** HKDF salt/info yerleşimi doğru; tanınmayan amaç sessizce farklı anahtar üretmek yerine fırlatıyor; kısa/boş kök anahtar reddediliyor; ham binary dönülüyor. `APP_KEY`'den ayrılık hem kodda hem testte.
- **Çapraz tenant eşleme yasağı aritmetiğe indirilmiş** (spec §3.2): aynı TCKN iki tenant'ta farklı hash — testli.
- **Dosya şifrelemesi doğru katmanda:** içerik depoya varmadan şifreleniyor, özet düz metin üzerinden, bucket private ve `SignedFileLink` bilinçli olarak storage path taşımıyor (reflection ile pinli, :248-260).
- **Kritik belge silme koruması veritabanında ve gürültülü** — chunk3 trigger deseniyle tutarlı, ham SQL ile denenmiş.
- **Anonimleştirme zinciri gerçek:** bu review'da PII probe ile uçtan uca yeniden üretildi; prod reddi kaçamaksız ve arch testiyle pinli.
- **Trait yazılmaması doğru karar.** Tüketicisi olmayan sembol üretmeme ilkesi chunk3 tutanağıyla tutarlı; plan kapsam notu (chunk4:12-14) kararı kayda bağlamış.
- **Kriter devirleri 1 ve 2 doğru:** kriter 3 ↔ chunk5 §5.9 D-4 birebir aynı kriter (tekilleştirme, F-13 emsali); kriter 5'in izin yarısı chunk6 §6.9/17'de açık atıfla kayıtlı.
- **`#[\SensitiveParameter]` + `zend.exception_ignore_args` katmanı** düşünülmüş ve arch testiyle pinli; `AppServiceProvider::register()` yerleşimi gerekçeli.

## ⚠️ Findings & Risks (in-scope for this review type)

**F-1 — `consents` silinebilir; plan §4.5'in "silinmez" hükmü karşılıksız (kırmızı çizgi 12 riski).**
Plan §4.5 tabloyu "tenant-kapsamlı, Auditable, **silinmez**" tanımlıyor. İmplementasyonda `TenantPolicy::apply('consents')` (migration `2026_07_26_001000:109`) standart dört politikayı üretiyor ve bu set `consents_delete` politikasını **içeriyor** (`TenantPolicy.php:98-99`) — yazma kapsamındaki bir tenant bağlamından DELETE geçer. Ne `documents`'taki gibi bir trigger ne `customers`'taki gibi bilinçli default-deny var. `Consent.php` yorumu "these records are never deleted" diyor ama hiçbir mekanizma bunu zorlamıyor. Rıza kaydı KVKK karşısında kanıt niteliğindedir; K-1'in "rıza kayıtları silinemez olduğu için dil sonradan eklenemez" gerekçesi de bu silinmezliğe dayanıyor — dayanağın kendisi kurulmamış.

**F-2 — Kritik belgenin `document_versions` satırları silinebilir (D-4/AD-11, kırmızı çizgi 12 riski).**
DELETE koruması yalnız `documents` tablosunda (migration:216-240). `document_versions` standart delete policy taşıyor (migration:201) ve trigger'ı yok. İmzalı yoklamanın kanıt yükünü taşıyan şey sürüm satırıdır (`content_hash`, `storage_path`, `key_version`) — kritik belgenin `documents` satırı silinemezken sürüm satırları doğrudan DELETE ile yok edilebilir; geriye özeti ve deposu olmayan bir kabuk kalır. `cascadeOnDelete` (migration:174) kritik olmayan belgelerde güvenli, ama sürüm satırlarının bağımsız silinebilirliği D-4'ün "sürümlü saklanır, silinmez" güvencesini yan kapıdan boşaltıyor.

**F-3 — Kriter 1 devredilmeden askıda kaldı; `TenantEncrypted` mekanizması tamamen testsiz; kriter 4'ün alan tarafı mekanizmasız.**
(a) Kriter 1 ("hassas alanlar veritabanında şifreli") hassas alan taşıyan tablo gerektirir; tablolar chunk5'te. Kriter 3 üstü çizilip devredilmişken kriter 1 §4.8'de işaretsiz duruyor — index'in "sonraki chunk'a bağımlı kriter yoktur" kuralına aykırı, defter kaydı eksik. (b) Daha önemlisi: `TenantEncrypted` cast'inin **tek bir fonksiyonel testi yok** (tek referansı arch testindeki `SensitiveParameter` listesi). Şifrele-çöz roundtrip'i, tenant'ın **satırdan** okunduğu, NULL tenant reddi, iki tenant'ın birbirinin değerini çözemediği — hiçbiri sınanmamış. Chunk3'ün `AuditProbeModel` emsali bu sınamayı tablo beklemeden mümkün kılıyordu; "mekanizma burada, kanıt chunk5'te" savunması hash için geçerli (testli) ama cast için mekanizmanın kendisi kanıtsız. (c) Kriter 4'ün "`key_version` şifreli değerlerin yanında" hükmü yalnız dosya tarafında karşılanmış; alan şifrelemesinde cast yalnız şifreli değeri yazıyor, sürümün nereye yazılacağına dair ne mekanizma ne kayıt var — chunk5'e devri de kayıtsız.

**F-4 — D-3'ün "bağlantı üretilirken yetki + tenant kontrolü" yarısı ne kodda ne devir kaydında.**
`issueLink()` (`SecureFileStore.php:108-129`) hiçbir yetki veya kullanıcı↔tenant üyelik kontrolü yapmıyor; `claim()` yalnız kullanıcı id/tür eşliyor. İzin altyapısının chunk6'da olması bunu açıklar — ama kriter 5'in izin yarısı açıkça devredilmişken D-3'ün bu yarısı için kayıt yok. Chunk6 §6.9 madde 17 yalnız `document.view_special_category`'yi bağlıyor; `document.download` izni §6.4 listesinde var fakat "bağlantı üretimi bu izinden geçer" davranışını sınayan kriter hiçbir chunk'ta yok. Sözleşme yazılmazsa chunk6 implementasyonu bu kapıyı kurmayı bilmeden geçebilir.

**F-5 — Kullanıcının dosya adı düz metin olarak `storage_path`'e gömülüyor; maskeleme kapsamı dışında.**
`path()` (`SecureFileStore.php:174-183`) `Str::slug(pathinfo($filename))` ile kullanıcıdan gelen dosya adını yola işliyor. Kimlik fotokopisi ve SGK evrakı pratikte TCKN ile adlandırılır (`12345678901.pdf`); rakamlar slug'dan aynen geçer. Sonuç: içerik şifreliyken TCKN, düz metin olarak (a) `document_versions.storage_path` kolonunda, (b) bucket nesne adında yaşar — ve `anon-rules.sql` `storage_path`'i **maskelemez** (yalnız `title` ve `cancellation_reason`, :63-66), yani prod dışı dump'lara ham gider. Testin kendisi bile `'kimlik.pdf'` adıyla yüklüyor. D-1'in güvencesi dosya adı kanalından deliniyor.

**F-6 — "Tek kullanımlık" garanti eşzamanlılıkta tutmuyor; kod yorumu aksini iddia ediyor.**
`claim()` `cache()->pull()` kullanıyor (`SecureFileStore.php:143`) — Laravel'de pull get+forget'tır, atomik değildir (Redis'te ayrı GET ve DEL). İki eşzamanlı istek ikisi de değeri okuyup ikisi de başarılı olabilir. Yorum (:134-136) tam tersini iddia ediyor: "even if the same link is opened twice in the same instant: the second caller finds nothing" — bu iddia yanlış ve gelecekteki okuyucuya sahte güven verir. Etki aynı kullanıcıyla sınırlı (bağlılık kontrolü ayakta) ama D-2/D-3'ün "tekil/tek kullanımlık" hükmü eşzamanlı pencerede sağlanmıyor ve mevcut test (:93-111) yalnız ardışık senaryoyu sınıyor.

**F-7 — Kriter 7'nin "süresi dolmuş bağlantı reddediliyor (test)" yarısının testi yok.**
`SensitiveDataTest.php` tek kullanım (:93-111) ve yabancı kullanıcı (:113-127) senaryolarını sınıyor; TTL dolmuş bağlantının reddi hiçbir testte yok — davranış yalnız cache TTL'ine emanet. Kriter metni testi açıkça istiyor.

**F-8 — Dil kuralı ihlali: `anonymize-dump.sh` ve `anon-rules.sql` baştan sona Türkçe.**
Yorumlar, `die`/`echo` mesajları ve kural açıklamaları Türkçe (`anonymize-dump.sh:3-15,26-46,…`; `anon-rules.sql:1-13,…`). CLAUDE.md dil kuralı mutlaktır ve "kod içi yorumlar, log mesajları" İngilizcedir; bu iki dosya bu chunk'ta doğdu. Arch testi de Türkçe dizgeyi pinliyor (`tests/Arch/SensitiveDataTest.php:110` — `'REDDEDILDI: APP_ENV='`), yani ihlal test sözleşmesine de işlenmiş durumda. (Faz 0'dan kalan scriptlerdeki Türkçe bu review'un kapsamı dışıdır; burada bulgu, bu chunk'ın yeni dosyalarıdır.)

**F-9 — `SignedFileLink::__construct($token)` `#[\SensitiveParameter]` işaretsiz; arch listesi eksik.**
`claim($token)` ve `cacheKey($token)` işaretliyken canlı yeteneği taşıyan nesnenin kurucusundaki `$token` (`SignedFileLink.php:20-23`) işaretsiz ve arch testinin beklenen listesinde yok (:129-133). `#[\SensitiveParameter]` ini ayarından bağımsız her ortamda redakte eder — debug'da `zend.exception_ignore_args` bilinçli olarak kapalıyken kurucu argümanı trace'e düşer; token 5 dakika canlı, tek kullanımlık bir erişim yeteneğidir.

## ❓ Open Questions (to be answered before next stage)

1. **NULL tenant reddi ve ofis tarafının hassas alanları (geliştiricinin 1. sorusu):** Bugünkü kapsamda meşru bir akışı kırmıyor — chunk4/5 modellerinde şifreli alan taşıyan ofis kaydı yok. Ancak eğitmen TCKN/IBAN'ı (cari) ofis alanıdır ve tenant'sızdır; `TenantEncrypted` bu veriyi yapısal olarak reddeder. Ofis tarafı hassas alanların hangi anahtarla şifreleneceği hangi aşamada karara bağlanacak? Kayıtsız kalırsa ilgili spec'te varsayımla doldurulma riski var.
2. **`searchHash` normalizasyonu (2. soru):** TCKN için güvenli; e-posta için lowercase+boşluk temizliği pratikte aynı kutuya çıkar, risk görmüyorum. Ancak ad-soyad gibi serbest metin alanlarına aynı hash'in kimlik eşleme amaçlı uygulanması ayrı bir karardır — chunk5'te hangi alanların hash'leneceği listelenirken bu sınır yazılmalı mı?
3. **`documents.document_type_id` FK'sız ve polymorphic yapı (4. soru):** Migration yorumları kararı kayda bağlamış; Spec 2 geldiğinde FK'nın eklenmesi ve tip'siz yüklenmiş mevcut belgelerin geriye dönük durumu (backfill mi, nullable kalıcı mı) o spec'te açıkça ele alınmalı. Şimdilik sorun değil.
4. **`anon-rules.sql` kapsam garantisi (5. soru):** `labelled > 0` kontrolü yalnız "hiç kural uygulanmamış" felaketini yakalar; yeni hassas kolonun unutulması gerçekten sessizdir — F-5 bunun ilk somut örneğini bugünden veriyor (`storage_path`). Daha güçlü bir garanti (ör. kolon envanteri ile karşılaştırma) plan değişikliği gerektirir; karar Sets'in. Bulgu olarak yalnız somut boşluğu (F-5) raporladım.
5. **Paylaşımlı cache (3. soru):** Token 64 karakter alfanumerik (~380 bit), anahtar `sha256(token)`; başka tenant'ın anahtarını tahmin etmek hesapsal olarak imkânsız, kullanıcı bağlılığı ayrıca kontrol ediliyor. Bulgu yok — atomiklik konusu F-6'da ayrı.

**Kriter devirleri (geliştiricinin açık sorusu):** Üç devirden **1 ve 2 doğru** (kanıtlar matrix'te). **3'ün ilkesi doğru** (chunk2 emsali; trait yazmama kararı da doğru) ancak defteri eksik: kriter 1 kriter 3 gibi işaretlenmedi ve cast mekanizmasının kendisi probe ile sınanabilirken sınanmadı — F-3.

## ⚠️ Upstream Findings

Yok. Bulguların tamamı implementasyon/kriter-defteri düzeyinde; spec veya plan kararına dönüş gerektiren bulgu çıkmadı.

## Verdict: ❌ REJECTED

Matrix'te 1 ❌'lı satır + 6 ⚠'lı satır var ve dokuz bulgu açık. İkisi kırmızı çizgi 12'ye (kritik kayıt silinmezliği) dokunan veritabanı düzeyi boşluk (F-1, F-2); biri şifreleme güvencesini dosya adı kanalından delen sızıntı (F-5); ikisi mekanizma/kanıt eksiği (F-3, F-7); biri sözleşmesiz kalmış güvenlik kapısı (F-4); biri yanlış iddia taşıyan eşzamanlılık boşluğu (F-6); ikisi disiplin ihlali (F-8 dil, F-9 arch listesi). Teslimatın çekirdeği — anahtar türetme, dosya şifreleme, rıza şeması, anonimleştirme — sağlam ve bağımsız doğrulandı; bulgular kapatılıp re-review talep edildiğinde hızlı bir tur beklenir.
