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

> **Review type:** impl-review (re-review)
> **Previous review:** `review-omurga-chunk4-impl-2026-07-26.md` (heyet 137 — ❌ REJECTED, 9 bulgu)
> **Upstream document(s) read:** `docs/plans/omurga-plan_chunk4.md` (plan v2.5, §4.8 güncel), `docs/specs/omurga-spec.md` v1.10, `docs/plans/omurga-plan_chunk5.md` §5.9 (D-6/D-7), `docs/plans/omurga-plan_chunk6.md` §6.9 (madde 18)
> **Other cross-referenced documents:** `app/Models/Concerns/BelongsToTenant.php`, `app/Support/Tenancy/TenantContext.php`, chunk2 §2.4 yazma kapsamı
> **Stage locks in effect:** Spec v1.10 + Plan v2.5 locked

**Upstream sabitliği doğrulandı:** `main`'deki spec başlığı **v1.10** (`omurga-spec.md:3`); v1.11 içeriği `80cf17e` ile geri alınmış ve `feature/EGT-33-kimlik-alanlari` dalında (lokal + origin, doğrulandı). Bu review v1.10'a karşı yapılmıştır. Ayrıca fix commit'i (`740fa43`) r1 review dosyasını **yalnız eklemiş** (0 silme) — kayıt değiştirilmemiş.

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

- Tam paket: **276/276 test geçti** (846 assertion) — CI #729 iddiası bağımsız doğrulandı. Pint temiz (`pint --test` passed).
- **F-5/F-8 uçtan uca:** canlı DB'ye `original_filename = '12345678901.pdf'` olan `document_versions` probe satırı yazıldı, dump alındı, `anonymize-dump.sh` koşuldu — TCKN'li dosya adı kaynak dump'ta **1**, maskeli dump'ta **0**, `masked.bin` değeri mevcut, **13 kolon maskelendi**. Probe satırları silindi (silme, kritik-olmayan sürüm satırının silinebildiğini canlıda ayrıca gösterdi — trigger'ın darlığı).
- **Dil taraması:** `anonymize-dump.sh` + `anon-rules.sql` üzerinde Türkçe karakter taraması **temiz** (ripgrep; macOS grep'in §/— byte'larından ürettiği yanlış pozitifler elendi).

---

## Bulgu kapanış matrisi (heyet 137 → r2)

| Bulgu | Durum | Kanıt |
|---|---|---|
| **F-1** consents silinebiliyordu | ✅ Kapalı | `app.deny_consent_delete()` + `consents_protect` trigger'ı (migration:126-144), WHEN'siz — tüm satırlar; test `refuses to delete a consent record` (ham SQL, `QueryException`). Trigger tercihi gerekçeli: politikasızlık sessizdir (chunk2 deney 10). `down()` temizliği eklendi |
| **F-2** kritik belgenin sürüm satırları silinebiliyordu | ✅ Kapalı | `app.deny_critical_version_delete()` (migration:254-272): ebeveynin `is_critical`'i fonksiyonda okunuyor (WHEN alt sorgu alamaz — doğru), ebeveyn yoksa `coalesce(..., false)` ile cascade'e izin. Üç yol da testli: kritik sürüm reddi, kritik olmayan sürüm silinebilir, kritik olmayan ebeveyn cascade'i (r1'den kalan test). Cascade analizi aşağıda (soru 2) |
| **F-3** `TenantEncrypted` testsizdi | ✅ Kapalı — **ve test gerçek kusur buldu** | `CryptoProbeModel` (chunk3 `AuditProbeModel` emsali) + `TenantEncryptionTest.php` 6 test: ham kolon şifreli, çapraz anahtar `DecryptException`, çok-tenant'lı okumada tenant **satırdan**, belirsiz kapsamda yazma reddi, NULL tenant okuma reddi, `key_version` > 0. Bulunan kusur (cast atama anında ↔ `BelongsToTenant` `creating`'de) yazma-kapsamına-düşme ile kapatıldı — sınır analizi aşağıda (soru 1). Kriter 1 ve 4 annotasyonları + chunk5 D-6/D-7 defter kaydı yazıldı |
| **F-4** D-3 yetki yarısı kayıtsızdı | ✅ Kapalı | chunk4 kriter 8'e devir annotasyonu; **chunk6 §6.9 madde 18**: `document.download` izni + kullanıcı↔tenant üyeliği, izinsiz istekte bağlantı **üretilmiyor**, test şartı — kesin ve doğru adreste |
| **F-5** dosya adı `storage_path`'e gömülüyordu | ✅ Kapalı | `path()` artık yalnız tenant/belge/sürüm + `Str::random(24)` (`SecureFileStore.php:201-210`) — kullanıcı metni yok; ad `document_versions.original_filename` (nullable, fillable) kolonunda; `anon-rules.sql:71-72` maskeliyor; test `keeps no user supplied filename in the storage path` `12345678901.pdf` ile. Uçtan uca yukarıda doğrulandı |
| **F-6** tek kullanımlık garanti atomik değildi | ✅ Kapalı | `cache()->lock($key.':claim', seconds: 10)`, `get()` bloklamasız; pull `try/finally` içinde, kilit her yolda bırakılıyor (`SecureFileStore.php:148-175`). Kilidi alamayan "expired or was already used" alıyor — kuyruk yok. Yanlış yorum, kusuru anlatan doğru yorumla değiştirildi. Anahtar/mesaj analizi aşağıda (soru 3) |
| **F-7** süre dolumu testi yoktu | ✅ Kapalı | `refuses a link whose time has run out` — `travel(ttl + 60)` ile, `config('security.file_link_ttl')` üzerinden |
| **F-8** iki dosya Türkçeydi | ✅ Kapalı | İki dosya da İngilizce; arch pin `REFUSED: APP_ENV=` (`tests/Arch/SensitiveDataTest.php`), `CREATE DATABASE`'den önce gelme şartı korunmuş; Türkçe karakter taraması temiz (bağımsız) |
| **F-9** `SignedFileLink::__construct($token)` işaretsizdi | ✅ Kapalı | `#[\SensitiveParameter]` + gerekçe yorumu (`SignedFileLink.php:19-25`); arch listesine `__construct => ['token']` eklendi |

## Odak sorularının cevapları

**1. F-3 düzeltmesi doğru sınırda mı?** Attribute yolundan geçen her yazma için **evet**: `create()`, `fill()`, `forceFill()`, `updateOrCreate()` hepsi `setAttribute` üzerinden cast'e uğrar; fallback (`TenantContext::current()`, `count === 1`, `scope[0]`) `BelongsToTenant::creating`'in doldurma kuralıyla **birebir aynı** (`BelongsToTenant.php:37-45`) — ikisi ayrışamaz. Tenant değişimi de yapısal olarak kapalı: `updating` kancası `tenant_id` dirty ise fırlatıyor (:48-61), yani "eski anahtarla şifrele, yeni tenant'a kaydet" senaryosu oluşamaz. **Tutmayan yer:** query-builder yazmaları — `Model::insert()`, `DB::table()->insert()`, `Model::query()->update([...])` cast'i **hiç çalıştırmaz** (Laravel'in kendi `encrypted` cast'i dahil tüm cast'lerin ortak özelliği) ve düz metin **sessizce** kolona iner; `BelongsToTenant` da koşmaz. Bu, fallback'in değil cast mekanizmasının sınırıdır ve cast içinden kapatılamaz — aşağıda açık soru 1 olarak chunk5/chunk9'a yönlendirdim.

**2. F-2 trigger'ı cascade'de deterministik mi?** **Evet, garanti — zamanlama şansı değil.** PostgreSQL'de `ON DELETE CASCADE`, ebeveyn tablodaki **AFTER DELETE** iç RI trigger'ıyla uygulanır: çocuk DELETE'leri ebeveyn satırı silindikten sonra, daha geç bir command id ile koşar; çocuğun BEFORE DELETE trigger'ındaki `SELECT ... FROM documents` ebeveyni **hiçbir durumda** göremez → `NULL` → `coalesce(false)` → izin. Ebeveyn hâlâ görünürken tetiklenen tek durum doğrudan çocuk silmesidir — kontrolün amaçlandığı yol. Kritik ebeveynin cascade'i zaten başlatamayacağı (kendi BEFORE trigger'ı önce fırlatır) doğru gözlem. Üç kombinasyon da CI'da testli; kritik-olmayan doğrudan sürüm silmesini bu review'un e2e temizliğinde canlıda ayrıca gördüm.

**3. F-6 kilidi doğru anahtarda mı, mesaj sızdırıyor mu?** Anahtar doğru: `'file-link:'.sha256(token).':claim'` — çekişme yalnız aynı token üzerinde, farklı token'lar hiç yarışmaz, token ham hâliyle cache'e girmiyor. Mesaj paritesi doğru kurulmuş: var olmayan token'da kilit **alınır** (çekişme yok), pull `null` döner ve **aynı** "expired or was already used" mesajı üretilir — kilit-alınamadı yolu ile ayırt edilemez, oracle yok; bilinçli olduğu anlaşılıyor ve doğru. İki not: (a) kilit cache store'un lock desteğine dayanır — Redis/array'de (prod/test) mevcut, sorun değil; (b) yanlış kullanıcının denemesi token'ı **yakıyor** (pull, kullanıcı kontrolünden önce) — güvenli yön (çalıntı token'la yoklama linki öldürür, meşru kullanıcı yeniden üretir ve bu yeniden erişim logu üretir), ancak bilinçliyse bir cümlelik yorum hak ediyor. Not düzeyinde.

**4. `original_filename` yeni sızıntı yüzeyi mi?** Kalan yüzey `documents.title` ile aynı sınıfta: düz metin metadata, RLS'li tenant tablosunda, dump maskelemesinde. Bucket nesne adından ve `storage_path` kolonundan çıkarılması gerçek kazanım — dış yüzey (depo sağlayıcısı, bucket listing, path log'ları) kapandı; kalan yüzey DB-içi. Şifrelenmesini zorlayan upstream hüküm yok (§4.1 alan listesi dosya adını saymıyor). Kabul edilebilir; istenirse chunk5'te `TenantEncrypted`'e alınması bir satırlık iş — karar Sets'in (açık soru 2).

**5. Devir kayıtları doğru ve kesin mi?** **Evet, üçü de.** chunk5 §5.9 D-6 (tablo yarısı, "ham SQL ile anlaşılmaz") ve D-7 (her hassas tabloya `key_version` kolonu + `TenantKeyDeriver::version()` değeri — operasyonel, uygulayıcıya soru bırakmıyor); chunk6 §6.9 madde 18 (izin + üyelik + "bağlantı üretilmiyor" + test şartı, kaynak chunk4 kriter 8'e geri bağlanmış). chunk4 §4.8'deki 1/4/8 annotasyonları devirlerin gerekçesini ve emsalini taşıyor.

## ✅ What works

- Dokuz kapanışın dokuzu da **gerçek mekanizmayla** kapatılmış — hiçbiri yorum/dokümantasyon makyajı değil; ikisi (F-1, F-2) veritabanı seviyesinde, biri (F-3) kapatılırken **gerçek bir kusur çıkarmış** (cast/`creating` sıralaması — `create()` ile şifreli alan taşınamıyordu). Testin kusuru bulması, r1'deki "mekanizma kanıtsız" itirazının yerinde olduğunun en iyi kanıtı.
- Fallback tasarımı disiplinli: yazma tarafı yalnız **tek**-tenant kapsama düşüyor, okuma tarafı **asla** düşmüyor — tehlikeli yön (ortam bağlamıyla çözme) kapalı kalmış.
- Upstream sabitliği ihlali (v1.11'in main'e erken commit'i) kullanıcı tarafından fark edilip **review sürerken** geri alınmış ve dallanmış — pin disiplini korunmuş.

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

Blocker bulgu **yok**. Not düzeyinde üç kalem (yeni tur gerektirmez):

- **N-1:** Plan chunk4 §4.4 "Silme koruması" cümlesi hâlâ yalnız `documents` trigger'ını sayıyor; implementasyonda artık iki trigger var (`documents_protect_critical`, `document_versions_protect_critical`). Bir cümlelik doc-sync — bir sonraki plan revizyonunda §4.4'e işlenmeli.
- **N-2:** `claim()`'de yanlış-kullanıcı denemesinin token'ı yakması güvenli yönde ama yorumsuz; bilinçliyse bir cümle yazılmalı (bkz. soru 3b).
- **N-3:** `CryptoProbeModel`'in probe tablosu RLS'siz kuruluyor — bilinçli (cast davranışını RLS'ten izole ediyor) ve dosya yorumu bunu söylüyor; kayda geçiriyorum ki gelecekte "RLS'siz tenant tablosu" diye yanlış emsal okunmasın.

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

1. **Query-builder yazmaları cast'i atlar** (soru 1'in cevabındaki sınır): chunk5'te gerçek şifreli kolonlar doğduğunda, `Model::query()->update(['national_id' => …])` gibi bir toplu yazma düz metni **sessizce** kolona indirir ve D-6'nın mutlu-yol testi bunu yakalamaz. Öneri karar için: chunk5 D-6'ya bir cümle (şifreli kolonlara yalnız model attribute yolundan yazılır) ve/veya chunk9 §9.5'e taranabilir bir kural adayı. Karar Sets'in.
2. **`original_filename` şifrelensin mi?** Bugün title emsaliyle düz metin + maske; TCKN'li dosya adları için DB-içi yüzey açık kalıyor. chunk5'te `TenantEncrypted`'e almak ucuz — karar Sets'in.

## ⚠️ Upstream Findings

Yok. (N-1 plan metni senkronudur, karar değişikliği değildir.)

## Verdict: ✅ APPROVED

Heyet 137'nin dokuz bulgusunun dokuzu da bağımsız olarak kapalı doğrulandı — kanıtlar dosya+satır ve gerçek koşumlarla: 276/276 test bu makinede yeniden üretildi, Pint temiz, F-5 maskelemesi TCKN'li dosya adı probe'uyla uçtan uca canlıda doğrulandı, dil taraması temiz, upstream pin (spec v1.10) main'de teyitli. Kalan üç kalem not, iki kalem açık soru düzeyindedir ve chunk4'ün kabulünü etkilemez; 1 ve 2 numaralı açık soruların cevabı chunk5 turundan önce verilmelidir. Son karar Sets'indir.
