# Audit zinciri ekranı — implementasyon planı

> ✅ **UYGULANDI (10.08.2026).** Dokuz görevin dokuzu da yazıldı ve commit'lendi.
> Aşağıdaki plan olduğu gibi bırakıldı; uygulanırken ortaya çıkan ve plandan
> tahmin edilemeyecek olanlar §"Uygulamada ortaya çıkanlar" başlığında.

> **Ajan işçiler için:** Bu plan görev görev uygulanır. Adımlar `- [ ]` kutucuklarıyla izlenir. Her görev kendi test döngüsünü taşır ve bağımsız olarak commit edilir.

**Kaynak spec:** `docs/specs/audit-zinciri-ekrani-spec.md` (EGT-92) · **Ticket:** EGT-92

**Hedef:** Backoffice'e, audit zincirinin durumunu gösteren ve bilirkişiye teslim edilebilir bağımsız bir doğrulama paketi üreten bir ekran eklemek.

**Yaklaşım:** Zincirin kendisi kurulu ve çalışıyor (`SealAuditBatch`, `VerifyAuditChain`, `PublishAuditRoots`, `ArchiveAuditBatch`). Bu plan **hiçbir zincir mantığını yeniden yazmaz** — okur, gösterir, paketler. Yeni yazılan üç şey var: bulgu kaydı tablosu, paket üreticisi, ve ekran.

**Stack:** Laravel 12 · Filament 5 (backoffice paneli) · Livewire · PostgreSQL 18 + TimescaleDB · Pest

---

## Global kısıtlar

Her görevin gereksinimleri bunları **kapsar**; ayrıca tekrar edilmez.

- **Dil:** kod/tablo/kolon/route/izin/enum/yorum **İngilizce**; kullanıcıya görünen her metin **Türkçe** ve `lang/tr/` üzerinden. Koda gömülü kullanıcı metni yok.
- **Kod yerleşimi** (`docs/arch/kod-yerlesimi.md`): iş kuralı `app/Domain/`, orkestrasyon `app/Actions/`, okuma `app/Queries/`, framework'e bağlı yardımcı `app/Support/<Konu>/`, dış SDK `app/Integrations/<Saglayici>/`. Filament sayfası şema taşımaz, sorgu yazmaz.
- **Test grubu zorunlu:** bu planın her testi `->group('omurga')` taşır (audit omurgaya aittir; `tests/Arch/TestDisciplineTest.php` grupsuz testi reddeder).
- **Testte çıplak `now()` yok** — `setTestNow` / `FrozenClock` (AD-26).
- **`env()` yalnız `config/` içinde** (kırmızı çizgi 7).
- **Migration'sız DDL yok** (kırmızı çizgi 6). Şema değişikliğinin tek yolu commit'lenmiş migration.
- **Hassas kayıt hard delete edilmez** (AD-11).
- **Kadans:** kalem üzerinde çalışırken yalnız dokunulan testin filtresi; commit'ten önce `./vendor/bin/pint --dirty` → `composer analyse` → ilgili grup. ⛔ Her düzenlemeden sonra tüm suite koşulmaz.
- **Commit:** `#EGT-92 work <süre>` ilk satırda, ikinci satırda conventional commit. AI attribution yasak.

**Doğrulanmış mevcut arayüzler** (bu plan bunlara dayanır, hepsi okundu):

| Ne | Nerede | İmza / şekil |
|---|---|---|
| Mühür modeli | `app/Models/AuditStamp.php` | `audit_table, epoch_date, from_id, to_id, row_count, merkle_root, previous_chain_hash, chain_hash, hash_algorithm, witnessed_at, witness_reference, archived_at, archive_path` |
| Zincir doğrulama | `app/Jobs/VerifyAuditChain.php` | `handle(AuditWitness $witness): array` → `list<array{audit_table: string, from_id: int, to_id: int, problem: string}>` |
| Mühür kuralı | `App\Jobs\SealAuditBatch::RULE` sabiti, `SealAuditBatch::link(?string $previous, string $root): string` | |
| Kök fonksiyonu | PostgreSQL | `app.audit_batch_merkle_root(text, bigint, bigint)` → `(merkle_root, row_count)` |
| Delil tabloları | `config('audit.evidence_tables')` | `['audit_writes', 'audit_access']` |
| Tanık | `app/Integrations/AuditWitness/AuditWitness.php` | `published(string $table): array` |
| Backoffice izin listesi | `config/authorization.php` `backoffice` bloğu (satır 393+) | |
| Filament sayfa deseni | `app/Filament/Backoffice/Pages/AccessLog.php` | `canAccess(): bool`, `mount()` içinde `abort_unless`, `getNavigationGroup()`, view `resources/views/filament/backoffice/pages/*.blade.php` |
| Discord | `app/Integrations/Discord/DiscordChannel.php` (3 case), `config/notifications.php` `discord` (3 anahtar), `app/Support/Notifications/DiscordFeed.php` | |
| Discord arch kilidi | `tests/Arch/NotificationChannelTest.php` — "never writes to the channels the outage bot owns" + "reads no debug webhook out of the environment" | |

---

## Dosya haritası

**Yeni:**

| Dosya | Sorumluluk |
|---|---|
| `database/migrations/2026_08_11_000100_create_audit_chain_findings_table.php` | Bulgu tablosu + append-only trigger |
| `app/Models/AuditChainFinding.php` | Bulgu kaydı |
| `app/Queries/Audit/ChainOverview.php` | Dört kart + 30 günlük şerit + mühür listesi (okuma) |
| `app/Queries/Audit/SealDetail.php` | Seçili mührün künyesi + ağaç verisi |
| `app/Domain/Audit/ChainFindingReason.php` | Bulgu sebebi enum'ı (framework'süz) |
| `app/Actions/Audit/RecordChainFindings.php` | Doğrulama sonucunu bulguya çevirir, bildirir |
| `app/Actions/Audit/VerifySingleSeal.php` | Tek mühür doğrulaması (senkron) |
| `app/Support/Audit/EvidencePackage.php` | Paket üreticisi (zip) |
| `resources/audit-evidence/BENIOKU.md` · `dogrula.py` · `sorgular.sql` · `yaprak-kurali.md` | Paketin sabit dosyaları |
| `app/Filament/Backoffice/Pages/AuditChain.php` | Ekran |
| `resources/views/filament/backoffice/pages/audit-chain.blade.php` | Ekranın görünümü |
| `lang/tr/audit_chain.php` | Ekranın tüm metni |

**Değişen:**

| Dosya | Ne |
|---|---|
| `config/authorization.php` | `audit.view_chain`, `audit.verify` (backoffice listesi) |
| `database/seeders/PermissionTemplateSeeder.php` | İki izin ilgili şablonlara |
| `app/Integrations/Discord/DiscordChannel.php` | Dördüncü case: `AuditAlert` |
| `config/notifications.php` | `audit_alert` webhook'u |
| `tests/Arch/NotificationChannelTest.php` | Dört case / dört anahtar beklentisi |
| `app/Jobs/VerifyAuditChain.php` | Sonucu `RecordChainFindings`'e verir |

---

## Görev 1: Bulgu kaydı — tablo, model, enum

**Dosyalar:**
- Oluştur: `database/migrations/2026_08_11_000100_create_audit_chain_findings_table.php`
- Oluştur: `app/Models/AuditChainFinding.php`
- Oluştur: `app/Domain/Audit/ChainFindingReason.php`
- Test: `tests/Feature/Omurga/AuditChainFindingTest.php`

**Arayüzler:**
- Üretir: `AuditChainFinding` modeli; `ChainFindingReason` enum'ı — `RowsChanged`, `ChainBroken`, `SelfInconsistent`, `WitnessMismatch`, `MissingLocally`, `MissingAtWitness`, `UnknownRule`, `RowCountChanged`.

⚠ Enum `app/Domain/` altında, yani **framework/Eloquent import edemez** (`tests/Arch/LayerTest.php`). Etiketi `lang/tr/audit_chain.php`'den okumaz — Blade çevirir.

- [ ] **Adım 1: Başarısız testi yaz**

```php
<?php

declare(strict_types=1);

use App\Domain\Audit\ChainFindingReason;
use App\Models\AuditChainFinding;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Tests\Concerns\RefreshDatabase;
use Tests\FrozenClock;

// ⚠ Çıplak `now()` yasak (AD-26). Suite bu andan başlar; bir bulgunun yaşı
// sınanacaksa oradan ileri gidilir.
$frozen = CarbonImmutable::parse(FrozenClock::INSTANT);

it('keeps a finding that cannot be closed or deleted', function (): void {
    /*
     * ⛔ AD-11 ve spec §6: kırılmış zincir bulgusunu "çözüldü" yapmak, delili
     * yönetilebilir yapmaktır. Kayıt açılır, not düşülür, silinmez.
     */
    $finding = AuditChainFinding::create([
        'audit_table' => 'audit_writes',
        'from_id' => 1,
        'to_id' => 100,
        'reason' => ChainFindingReason::RowsChanged->value,
        'sealed_root' => str_repeat('a', 64),
        'recomputed_root' => str_repeat('b', 64),
        'witness_root' => str_repeat('a', 64),
        'first_seen_at' => $frozen,
    ]);

    expect(fn () => $finding->delete())->toThrow(QueryException::class);
})->group('omurga');

it('lets a person add a note without changing the finding', function (): void {
    $finding = AuditChainFinding::create([
        'audit_table' => 'audit_access',
        'from_id' => 1,
        'to_id' => 50,
        'reason' => ChainFindingReason::WitnessMismatch->value,
        'sealed_root' => str_repeat('c', 64),
        'recomputed_root' => str_repeat('c', 64),
        'witness_root' => str_repeat('d', 64),
        'first_seen_at' => $frozen,
    ]);

    $finding->update(['note' => 'Tanık deposu 11.08 sabahı elle düzeltildi.']);

    expect($finding->fresh()->note)->toBe('Tanık deposu 11.08 sabahı elle düzeltildi.')
        ->and($finding->fresh()->reason)->toBe(ChainFindingReason::WitnessMismatch->value);
})->group('omurga');

it('records the same finding once', function (): void {
    // Aynı mühür her koşuda yeniden bulunur; her koşu yeni satır yazsaydı
    // tablo bir bulguyu yüzlerce kez tekrarlardı.
    $row = [
        'audit_table' => 'audit_writes',
        'from_id' => 1,
        'to_id' => 100,
        'reason' => ChainFindingReason::ChainBroken->value,
        'sealed_root' => str_repeat('e', 64),
        'recomputed_root' => str_repeat('e', 64),
        'witness_root' => null,
        'first_seen_at' => $frozen,
    ];

    AuditChainFinding::create($row);

    expect(fn () => AuditChainFinding::create($row))->toThrow(QueryException::class);
})->group('omurga');
```

- [ ] **Adım 2: Testi koş, düştüğünü gör**

Çalıştır: `./vendor/bin/pest tests/Feature/Omurga/AuditChainFindingTest.php`
Beklenen: FAIL — `Class "App\Models\AuditChainFinding" not found`

- [ ] **Adım 3: Enum'ı yaz**

```php
<?php

declare(strict_types=1);

namespace App\Domain\Audit;

/**
 * Zincir doğrulamasının bulabileceği her uyuşmazlık (spec §6).
 *
 * ⛔ Metin taşımaz. `VerifyAuditChain` bugün İngilizce cümleler üretiyor;
 * bir cümleyi tabloya yazmak, o cümle değiştiğinde geçmiş bulguları
 * okunamaz hâle getirir. Enum değeri sabittir, çeviri Blade'in işidir.
 */
enum ChainFindingReason: string
{
    case RowsChanged = 'rows_changed';
    case RowCountChanged = 'row_count_changed';
    case ChainBroken = 'chain_broken';
    case SelfInconsistent = 'self_inconsistent';
    case WitnessMismatch = 'witness_mismatch';
    case MissingLocally = 'missing_locally';
    case MissingAtWitness = 'missing_at_witness';
    case UnknownRule = 'unknown_rule';
}
```

- [ ] **Adım 4: Migration'ı yaz**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
 * Zincir doğrulamasının bulguları (EGT-92 spec §6).
 *
 * ⛔ Bulgu SİLİNMEZ ve "çözüldü" diye kapatılmaz (AD-11). Tabloda `resolved_at`
 * yoktur ve olmayacaktır: bir kolon varsa bir gün doldurulur, ve kırılmış zincir
 * bulgusunu kapatabilmek delili yönetilebilir yapar. Kişinin yazabileceği tek
 * şey `note`.
 *
 * ⚠ Tenant taşımaz — bir mühür her firmanın satırlarını birden kapsar ve
 * hiçbirine ait değildir (AuditStamp ile aynı sebep, config('tenancy.exempt_tables')).
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::create('audit_chain_findings', function (Blueprint $table): void {
            $table->id();

            $table->string('audit_table', 32);
            $table->unsignedBigInteger('from_id');
            $table->unsignedBigInteger('to_id');

            $table->string('reason', 32);

            // Üç kök yan yana: teşhisin kendisi budur — satırlar mı değişti,
            // mühür mü, yoksa yalnız yayımlanan kopya mı.
            $table->string('sealed_root', 64)->nullable();
            $table->string('recomputed_root', 64)->nullable();
            $table->string('witness_root', 64)->nullable();

            $table->timestampTz('first_seen_at', precision: 6);
            $table->timestampTz('last_seen_at', precision: 6)->nullable();
            $table->text('note')->nullable();

            $table->timestampsTz(precision: 6);

            // Aynı mühürdeki aynı sebep tek satırdır; koşu tekrarladıkça
            // `last_seen_at` tazelenir.
            $table->unique(['audit_table', 'from_id', 'reason']);
            $table->index('first_seen_at');
        });

        // ⛔ Silme veritabanı seviyesinde reddedilir. Uygulama katmanındaki bir
        // kural, tinker'dan atılan bir DELETE'i durdurmaz.
        DB::statement(<<<'SQL'
            CREATE TRIGGER audit_chain_findings_no_delete
            BEFORE DELETE ON audit_chain_findings
            FOR EACH ROW EXECUTE FUNCTION app.deny_mutation()
        SQL);
    }

    public function down(): void
    {
        Schema::dropIfExists('audit_chain_findings');
    }
};
```

⚠ `app.deny_mutation()` fonksiyonu `2026_07_26_000100_create_audit_tables.php` içinde tanımlı — önce **oku ve imzasını doğrula**; DELETE'i reddedip UPDATE'e izin verip vermediğini kontrol et. Vermiyorsa bu trigger yalnız `BEFORE DELETE` olarak bağlanır (yukarıdaki gibi) ve UPDATE serbest kalır, çünkü `note` yazılabilmeli.

- [ ] **Adım 5: Modeli yaz**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;

/**
 * Zincir doğrulamasının açtığı bulgu (EGT-92 spec §6).
 *
 * Kapatılamaz: tabloda `resolved_at` yoktur ve silme trigger'la reddedilir.
 *
 * @property string $audit_table
 * @property int $from_id
 * @property int $to_id
 * @property string $reason
 * @property string|null $sealed_root
 * @property string|null $recomputed_root
 * @property string|null $witness_root
 * @property CarbonImmutable $first_seen_at
 * @property CarbonImmutable|null $last_seen_at
 * @property string|null $note
 */
final class AuditChainFinding extends Model
{
    protected $table = 'audit_chain_findings';

    protected $guarded = [];

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'from_id' => 'integer',
            'to_id' => 'integer',
            'first_seen_at' => 'immutable_datetime',
            'last_seen_at' => 'immutable_datetime',
        ];
    }
}
```

- [ ] **Adım 6: Testi koş, geçtiğini gör**

Çalıştır: `./vendor/bin/pest tests/Feature/Omurga/AuditChainFindingTest.php`
Beklenen: PASS (3 test)

- [ ] **Adım 7: Pint, analyse, commit**

```bash
./vendor/bin/pint --dirty
composer analyse
git add database/migrations app/Models/AuditChainFinding.php app/Domain/Audit/ChainFindingReason.php tests/Feature/Omurga/AuditChainFindingTest.php
git commit -m "#EGT-92 work 45m

feat(audit): zincir bulgusu kaydi — kapatilamaz, silinemez

Tabloda resolved_at YOK ve olmayacak: bir kolon varsa bir gun doldurulur,
ve kirilmis zincir bulgusunu kapatabilmek delili yonetilebilir yapar (AD-11).
Silme veritabani triggeri ile reddedilir — uygulama katmanindaki kural
tinker'dan atilan DELETE'i durdurmaz."
```

---

## Görev 2: Doğrulama sonucu bulguya dönüşür

**Dosyalar:**
- Oluştur: `app/Actions/Audit/RecordChainFindings.php`
- Değiştir: `app/Jobs/VerifyAuditChain.php` (yalnız `handle()` — problem listesi üretildikten sonra)
- Test: `tests/Feature/Omurga/AuditChainFindingRecordingTest.php`

**Arayüzler:**
- Tüketir: `VerifyAuditChain::handle()` dönüşü — `list<array{audit_table: string, from_id: int, to_id: int, problem: string}>`.
- Üretir: `RecordChainFindings::__invoke(array $problems): int` — açılan/tazelenen bulgu sayısını döner.

⚠ **Sorun:** mevcut job problemi **İngilizce cümle** olarak üretiyor (`'the rows no longer produce the root that was sealed over them'`). Enum'a çevirmek için cümle→enum eşlemesi gerekir ve bu kırılgandır. **Bu görevde `VerifyAuditChain`'in kendisi enum taşıyacak şekilde düzeltilir:** `problem()` metodu cümle yerine `ChainFindingReason` değeri döner, cümleler `lang/tr/audit_chain.php`'ye taşınır.

- [ ] **Adım 1: Başarısız testi yaz**

```php
<?php

declare(strict_types=1);

use App\Actions\Audit\RecordChainFindings;
use App\Domain\Audit\ChainFindingReason;
use App\Models\AuditChainFinding;
use Carbon\CarbonImmutable;
use Tests\Concerns\RefreshDatabase;
use Tests\FrozenClock;

$frozen = CarbonImmutable::parse(FrozenClock::INSTANT);

it('opens one finding per problem', function (): void {
    $opened = app(RecordChainFindings::class)([
        [
            'audit_table' => 'audit_writes',
            'from_id' => 1,
            'to_id' => 100,
            'reason' => ChainFindingReason::RowsChanged,
            'sealed_root' => str_repeat('a', 64),
            'recomputed_root' => str_repeat('b', 64),
            'witness_root' => null,
        ],
    ]);

    expect($opened)->toBe(1)
        ->and(AuditChainFinding::count())->toBe(1)
        ->and(AuditChainFinding::first()->reason)->toBe(ChainFindingReason::RowsChanged->value);
})->group('omurga');

it('refreshes an existing finding rather than duplicating it', function (): void {
    // Aynı bulgu her gece yeniden bulunur. İkinci koşu yeni satır yazmaz,
    // last_seen_at tazeler — yoksa tablo bir bulguyu yüzlerce kez tekrarlar.
    $problem = [
        'audit_table' => 'audit_writes',
        'from_id' => 1,
        'to_id' => 100,
        'reason' => ChainFindingReason::ChainBroken,
        'sealed_root' => str_repeat('a', 64),
        'recomputed_root' => str_repeat('a', 64),
        'witness_root' => null,
    ];

    app(RecordChainFindings::class)([$problem]);
    $first = AuditChainFinding::first()->first_seen_at;

    $this->travelTo($frozen->addDay());
    app(RecordChainFindings::class)([$problem]);

    expect(AuditChainFinding::count())->toBe(1)
        ->and(AuditChainFinding::first()->first_seen_at->equalTo($first))->toBeTrue()
        ->and(AuditChainFinding::first()->last_seen_at)->not->toBeNull();
})->group('omurga');
```

- [ ] **Adım 2: Testi koş, düştüğünü gör**

Çalıştır: `./vendor/bin/pest tests/Feature/Omurga/AuditChainFindingRecordingTest.php`
Beklenen: FAIL — `Target class [App\Actions\Audit\RecordChainFindings] does not exist`

- [ ] **Adım 3: Action'ı yaz**

```php
<?php

declare(strict_types=1);

namespace App\Actions\Audit;

use App\Domain\Audit\ChainFindingReason;
use App\Models\AuditChainFinding;
use Illuminate\Support\Facades\Date;

/**
 * Doğrulama koşusunun bulduklarını kalıcı kayda çevirir (spec §6).
 *
 * ⛔ Kapatmaz, çözmez, silmez — yalnız açar ve tazeler. Bir bulgunun ne zaman
 * biteceği sorusunun cevabı bu sınıfta yoktur; bir insandadır.
 */
final class RecordChainFindings
{
    /**
     * @param  list<array{audit_table: string, from_id: int, to_id: int, reason: ChainFindingReason, sealed_root: ?string, recomputed_root: ?string, witness_root: ?string}>  $problems
     */
    public function __invoke(array $problems): int
    {
        $now = Date::now();
        $recorded = 0;

        foreach ($problems as $problem) {
            $finding = AuditChainFinding::firstOrNew([
                'audit_table' => $problem['audit_table'],
                'from_id' => $problem['from_id'],
                'reason' => $problem['reason']->value,
            ]);

            /*
             * ⚠ `firstOrNew` ve `updateOrCreate` DEĞİL. İkincisi ikinci çağrıda
             * verilen her alanı yazar — `first_seen_at` dahil — ve bulgunun ne
             * zaman ilk görüldüğü her gece bugüne kayardı. Bir bulgunun yaşı,
             * onun ne kadar süredir açık olduğunu söyleyen tek sayıdır.
             */
            if (! $finding->exists) {
                $finding->first_seen_at = $now;
            }

            $finding->fill([
                'to_id' => $problem['to_id'],
                'sealed_root' => $problem['sealed_root'],
                'recomputed_root' => $problem['recomputed_root'],
                'witness_root' => $problem['witness_root'],
                'last_seen_at' => $now,
            ])->save();

            $recorded++;
        }

        return $recorded;
    }
}
```

- [ ] **Adım 4: `VerifyAuditChain`'i enum taşıyacak şekilde düzelt**

`problem()` metodunun dönüşü `'problem' => string` yerine `'reason' => ChainFindingReason` olur; `verifyAgainstRows`, `verify` ve `compareWithWitness` içindeki yedi cümle karşılık gelen enum değerlerine çevrilir; `handle()` sonunda `app(RecordChainFindings::class)($problems)` çağrılır ve `Log::critical` **korunur** (spec §6: biri alarmdır, diğeri kayıttır).

⚠ `Log::critical` çağrısının parametresi değişmemelidir — EGT-95'te o satır Sentry'ye bağlandı (`config/logging.php` `sentry` kanalı, `critical` seviye). Mesajı değiştirmek Sentry'deki issue gruplamasını böler.

- [ ] **Adım 5: Testleri koş**

Çalıştır: `./vendor/bin/pest tests/Feature/Omurga/AuditChainFindingRecordingTest.php` ve mevcut zincir testleri: `./vendor/bin/pest --filter="AuditChain|VerifyAudit"`
Beklenen: hepsi PASS. Mevcut testler `problem` anahtarını bekliyorsa **onlar da bu görevde güncellenir** — enum'a geçiş bilinçli bir sözleşme değişikliğidir.

- [ ] **Adım 6: Pint, analyse, commit**

```bash
./vendor/bin/pint --dirty
composer analyse
git add app/Actions/Audit app/Jobs/VerifyAuditChain.php tests/Feature/Omurga/AuditChainFindingRecordingTest.php
git commit -m "#EGT-92 work 1h

feat(audit): dogrulama bulgulari kalici kayda yaziliyor

VerifyAuditChain artik Ingilizce cumle yerine ChainFindingReason tasiyor —
bir cumleyi tabloya yazmak, o cumle degistiginde gecmis bulgulari okunamaz
hale getirir. Ayni bulgu her gece yeniden bulunur; ikinci kosu yeni satir
yazmaz, last_seen_at tazeler.

Log::critical korundu: EGT-95'te o satir Sentry'ye baglandi, mesaji
degistirmek issue gruplamasini bolerdi."
```

---

## Görev 3: Discord alarm kanalı

**Dosyalar:**
- Değiştir: `app/Integrations/Discord/DiscordChannel.php`, `config/notifications.php`, `tests/Arch/NotificationChannelTest.php`, `.env.example`, `.env.production.example`, `.env`
- Test: `tests/Feature/Omurga/AuditChainAlertTest.php`

**Arayüzler:**
- Üretir: `DiscordChannel::AuditAlert` (`'audit_alert'`), `config('notifications.discord.audit_alert')`, `config('notifications.discord_bot_names.audit_alert')`.

⚠ **Bu görev iki arch testini bilinçli olarak değiştirir.** Spec §6.1'in gerekçesi commit mesajına ve testin yorumuna birebir yazılır — yoksa altı ay sonra biri "kural gevşetilmiş" diye geri alır.

- [ ] **Adım 1: Arch testini yeni beklentiyle güncelle**

```php
it('never writes to the channels the outage bot owns', function (): void {
    /*
     * ⛔ Kriter 14, ve 10.08.2026'da BİR olay için gevşetildi (EGT-92 spec §6.1).
     *
     * Kural şuydu: `debug` ve `prod-debug` Sentry'nin botuna aittir, bir
     * uygulama satırı orada beklenen uyarıyı gömer. Zincir bulgusunda bu tersine
     * döner — kırılmış bir audit zinciri tam olarak o kanalı açık tutan kişinin
     * görmek istediği şeydir.
     *
     * ⚠ Gevşetme TEK olaydır ve bu test onu kilitler: dördüncü case adıyla
     * beklenir, beşincisi eklenemez.
     */
    $names = array_map(static fn (DiscordChannel $one): string => $one->value, DiscordChannel::cases());

    expect($names)->toBe(['office', 'customer', 'accounting', 'audit_alert'])
        ->and(array_keys((array) config('notifications.discord')))
        ->toBe(['office', 'customer', 'accounting', 'audit_alert']);
})->group('omurga');

it('sends the audit alert under its own bot name', function (): void {
    // ⚠ Sentry botunun satırlarından ayrılmalı: ikisi farklı şey söyler —
    // biri "kod patladı", diğeri "delil zinciri tutmuyor".
    expect(config('notifications.discord_bot_names.audit_alert'))->toBe('3eegitim-audit-bot');
})->group('omurga');
```

⚠ `reads no debug webhook out of the environment` testi de güncellenir: `DISCORD_WEBHOOK_PROD_DEBUG` artık config'de **okunur**, ama yalnız `audit_alert` anahtarı altında. Test bunu tam olarak böyle yazmalı — `DISCORD_WEBHOOK_DEBUG` (prod olmayan) hâlâ yasak.

- [ ] **Adım 2: Testi koş, düştüğünü gör**

Çalıştır: `./vendor/bin/pest tests/Arch/NotificationChannelTest.php`
Beklenen: FAIL — üç case bulunur, dört beklenir.

- [ ] **Adım 3: Enum'a case ekle, config'i güncelle**

```php
// app/Integrations/Discord/DiscordChannel.php
case AuditAlert = 'audit_alert';
```

```php
// config/notifications.php
'discord' => [
    'office' => env('DISCORD_WEBHOOK_PROD_OFFICE'),
    'customer' => env('DISCORD_WEBHOOK_PROD_CUSTOMER'),
    'accounting' => env('DISCORD_WEBHOOK_PROD_ACCOUNTING'),

    /*
    | ⛔ prod-debug kanalı — ve bu, 10.08.2026'da bilinçli olarak gevşetilmiş
    | bir kuraldır (EGT-92 spec §6.1). Kural "debug kanalları uygulamanın işi
    | değildir" diyordu; kırılmış audit zinciri o kanalı açık tutan kişinin
    | görmek istediği tek şeydir. Gönderici adı ayrıdır, aşağıda.
    */
    'audit_alert' => env('DISCORD_WEBHOOK_PROD_DEBUG'),
],

/*
| Discord webhook'u `username` alanıyla gönderici adını override eder. Bu
| satır olmadan mesaj webhook'un kendi adıyla düşer ve Sentry botunun
| satırlarından ayrılmaz.
*/
'discord_bot_names' => [
    'audit_alert' => '3eegitim-audit-bot',
],
```

- [ ] **Adım 4: `DiscordFeed`'in gönderim yolunu bot adını taşıyacak şekilde genişlet**

`DiscordFeed`'in webhook POST gövdesine `username` alanı eklenir; ad `config('notifications.discord_bot_names.'.$channel->value)` ile okunur ve **null ise alan hiç gönderilmez** (diğer üç kanal kendi webhook adlarıyla kalır).

- [ ] **Adım 5: Bulgunun Discord'a düştüğünü doğrulayan testi yaz ve koş**

```php
it('posts a chain finding to the audit alert channel', function (): void {
    Http::fake();

    app(RecordChainFindings::class)([[
        'audit_table' => 'audit_writes',
        'from_id' => 1,
        'to_id' => 100,
        'reason' => ChainFindingReason::RowsChanged,
        'sealed_root' => str_repeat('a', 64),
        'recomputed_root' => str_repeat('b', 64),
        'witness_root' => null,
    ]]);

    Http::assertSent(function ($request): bool {
        return str_contains($request->url(), 'discord.com/api/webhooks')
            && $request['username'] === '3eegitim-audit-bot';
    });
})->group('omurga');
```

⚠ `Http::preventStrayRequests()` bypass edilmez (AD-26). Testin `Http::fake()` çağrısı yeterlidir; proje `TestCase`'inde stray koruması varsa ona uyulur.

- [ ] **Adım 6: Pint, analyse, commit**

⚠ Yeni env anahtarı **yoktur**: webhook zaten `DISCORD_WEBHOOK_PROD_DEBUG` olarak tanımlı ve bot adı config'de sabittir — bir ortam değişkeni olsaydı, ayırt ediciliği ortamdan ortama değişebilen bir isme bağlanmış olurdu.

```bash
./vendor/bin/pint --dirty
composer analyse
./vendor/bin/pest --group=omurga
git add app/Integrations/Discord config/notifications.php app/Support/Notifications tests/Arch/NotificationChannelTest.php tests/Feature/Omurga/AuditChainAlertTest.php
git commit -m "#EGT-92 work 45m

feat(audit): zincir bulgusu prod-debug kanalina 3eegitim-audit-bot adiyla

Kural gevsetildi ve gerekcesi spec 6.1'de: 'debug kanallari uygulamanin isi
degildir' kurali, bir uygulama satirinin beklenen uyariyi gomdugu icin vardi.
Kirilmis audit zinciri o kanali acik tutan kisinin gormek istedigi TEK seydir
— gomulen degil, beklenen uyari budur.

Gevsetme tek olaydir: arch testi dorduncu case'i adiyla bekler, besincisi
eklenemez. Gonderici adi ayri, ciplak gozle Sentry botundan ayrilir."
```

---

## Görev 4: Ekranın okumaları

**Dosyalar:**
- Oluştur: `app/Queries/Audit/ChainOverview.php`, `app/Queries/Audit/SealDetail.php`
- Test: `tests/Feature/Omurga/AuditChainOverviewTest.php`

**Arayüzler:**
- Üretir:
  - `ChainOverview::cards(): array{status: string, seals: int, witnessed: int, witnessed_missing: int, archived: int, archive_pending: int, first_sealed_on: ?string, verified_at: ?string}`
  - `ChainOverview::lastThirtyDays(): list<array{date: string, state: string}>` — `state` ∈ `verified|finding|no_seal`
  - `ChainOverview::seals(int $limit = 200): list<array{...}>` — tarih, tablo, satır aralığı, adet, kök, tanık, arşiv durumu
  - `SealDetail::for(int $stampId): array{stamp: array, tree: array, findings: list<array>}`

⚠ Bu iki sınıf **yalnız okur**. Doğrulama koşturmaz, bulgu yazmaz.

- [ ] **Adım 1: Başarısız testi yaz** — dört kartın sayıları, 30 günlük şeridin durum değerleri, mühürsüz günün `no_seal` dönmesi, tanıksız/arşivsiz sayımların doğruluğu. (Fixture: `AuditStamp::factory()` yoksa bu görevde yazılır — `database/factories/AuditStampFactory.php`.)

- [ ] **Adım 2: Testi koş, düştüğünü gör**

- [ ] **Adım 3: `ChainOverview` ve `SealDetail`'i yaz**

⚠ Ağaç verisi (`tree`) **gerçek Merkle ağacı değildir** — ekranda gösterilen, kökün satırlardan nasıl doğduğunu anlatan bir şemadır. Kaç yaprak olduğu (`row_count`) ve kökün kendisi gerçektir; aradaki düğümler çizim amaçlıdır ve view bunu **açıkça söyler** (bkz. Görev 6 metinleri). Gerçek ağacı kurmak `to_id - from_id` satırın tamamını okumak demektir ve bu ekran onu yapmaz.

- [ ] **Adım 4: Testi koş, geçtiğini gör**

- [ ] **Adım 5: Pint, analyse, commit**

---

## Görev 5: Doğrulama eylemleri

**Dosyalar:**
- Oluştur: `app/Actions/Audit/VerifySingleSeal.php`
- Test: `tests/Feature/Omurga/AuditChainVerificationTest.php`

**Arayüzler:**
- Tüketir: `SealAuditBatch::RULE`, `app.audit_batch_merkle_root(text, bigint, bigint)`, `AuditWitness::published()`
- Üretir: `VerifySingleSeal::__invoke(AuditStamp $seal): array{ok: bool, sealed_root: string, recomputed_root: ?string, witness_root: ?string, findings: list<ChainFindingReason>}`

- [ ] **Adım 1: Başarısız testi yaz** — üç senaryo: (a) sağlam mühür `ok: true` döner; (b) satırı **kasten bozulmuş** batch `ok: false` ve `RowsChanged` döner; (c) tanıktaki kök farklıysa `WitnessMismatch` döner.

⚠ (b) senaryosu spec kabul kriteri 6'dır ve bu planın en önemli testidir: **zincir gerçekten kırılabildiğini kanıtlamalıdır.** Bozma, append-only trigger'ı olan bir tabloya UPDATE atmayı gerektirir — test veritabanında trigger'ı geçici devre dışı bırakmak yerine, `audit_writes`'a doğrudan `DB::statement` ile yazılmış bir satırın mühürlenmesi ve **sonra** mühür kaydının `merkle_root`'unun değiştirilmesi daha dürüst bir kurulumdur (mühür tablosunda böyle bir trigger yoksa; `app.deny_seal_rewrite()` varlığı **önce doğrulanır**).

- [ ] **Adım 2: Testi koş, düştüğünü gör**
- [ ] **Adım 3: `VerifySingleSeal`'i yaz** — `VerifyAuditChain`'in tek mühür için olan hâli; ortak mantık ikisinde de tekrar edilmez, gerekiyorsa `VerifyAuditChain`'den özel metotlar bu action'a taşınır ve job onu çağırır.
- [ ] **Adım 4: Testi koş**
- [ ] **Adım 5: Pint, analyse, commit**

---

## Görev 6: Ekran — vitrin katmanı

**Dosyalar:**
- Oluştur: `app/Filament/Backoffice/Pages/AuditChain.php`, `resources/views/filament/backoffice/pages/audit-chain.blade.php`, `lang/tr/audit_chain.php`
- Değiştir: `config/authorization.php`, `database/seeders/PermissionTemplateSeeder.php`
- Test: `tests/Feature/Backoffice/AuditChainScreenTest.php`

⚠ **Test grubu burada `backoffice`** — dosya `tests/Feature/Backoffice/` altında ve her test dosyası tam olarak bir panel grubuna aittir (AD-26).

**Arayüzler:**
- Tüketir: `ChainOverview`, `SealDetail`
- Üretir: `AuditChain::canAccess(): bool`, `AuditChain::canVerify(): bool`

- [ ] **Adım 1: Başarısız testi yaz** — izinsiz hesap 403; `audit.view_chain` olan hesap sayfayı görür; `audit.verify` olmayan hesap dört düğmeyi **görmez**; dört kartın sayıları ekranda görünür; log yapısı bloğu üç tabloyu ve `audit_usage`'ın mühürlenmeme gerekçesini içerir.

- [ ] **Adım 2: Testi koş, düştüğünü gör**

- [ ] **Adım 3: İzinleri ekle**

`config/authorization.php` `backoffice` bloğuna, `audit.view_all_tenants` / `audit.view_access_log` satırlarının yanına:

```php
/*
| Zincirin durumunu okumak ve onu doğrulamak (EGT-92 spec §2).
|
| ⚠ Ofis ailesindeki `audit.view` DEĞİL — o başka bir ailenin izni ve bu ekran
| backoffice'te. Mevcut iki backoffice izni satır İÇERİĞİ okumaya dairdir ve
| bu ekrandan daha hassastır: bir mühür özeti kimseye ait değildir ve
| kapsadığı şeyi ele vermez. Daha az hassas bir ekranı daha dar bir kapının
| arkasına koymak, kapıyı anlamsızlaştırır.
|
| İkisi ayrı, çünkü paket DIŞARI ÇIKAN bir çıktıdır ve tam doğrulama maliyetli
| bir koşudur. Durumu okuyabilmek, bu ikisine karar verebilmek değildir.
*/
'audit.view_chain',
'audit.verify',
```

- [ ] **Adım 4: Dil dosyasını yaz** — `lang/tr/audit_chain.php`. Ekrandaki **her** metin burada: kart başlıkları, sütun adları, log yapısı bloğunun üç satırı ve gerekçesi, ağaç şemasının "bu bir çizimdir" uyarısı, sekme adları, düğme metinleri, bulgu sebeplerinin Türkçe karşılıkları (`ChainFindingReason` case başına bir anahtar).

- [ ] **Adım 5: Sayfayı ve view'ı yaz** — spec §3'teki kompozisyon birebir: dört kart → 30 gün şeridi → iki kolon (sol liste geniş, sağ dikey üç blok) → alt şerit → Mekanizma sekmesi.

⛔ **Tasarım Uygulama Protokolü burada geçerlidir.** Bu ekranın tasarımcı mockup'ı **yoktur** — `design/` altındaki 40 dosya tarandı, zincir/mühür/Merkle geçen bir ekran yok; en yakını `63 Yonetici Kullanici Log.dc.html` ve o kullanıcı yönetimi ile logları anlatıyor (ölçüldü, 10.08.2026). Referans bu yüzden spec §3'tür ve teslimden önce spec §3'e karşı üç sütunlu kapsama matrisi üretilir: *spec öğesi | ✓/⚠/❌ | kanıt (dosya + satır)*. Bellekten üretilen matris geçersizdir.

- [ ] **Adım 6: Testi koş, geçtiğini gör**
- [ ] **Adım 7: `PermissionTemplateSeeder`'a izinleri ekle ve seeder testini koş**
- [ ] **Adım 8: Pint, analyse, `--group=backoffice`, commit**

---

## Görev 7: Ekran — araç katmanı ve loglama

**Dosyalar:**
- Değiştir: `app/Filament/Backoffice/Pages/AuditChain.php` (dört eylem), `resources/views/.../audit-chain.blade.php`
- Test: `tests/Feature/Backoffice/AuditChainActionsTest.php`

- [ ] **Adım 1: Başarısız testi yaz** — (a) tek mühür doğrulaması senkron koşar ve üç kökü döner; (b) tam doğrulama **kuyruğa atılır** (`Queue::fake()` + `Queue::assertPushed(VerifyAuditChain::class)`), senkron koşmaz; (c) sayfayı açmak `audit_usage`'a satır yazar; (d) doğrulama koşturmak `audit_writes`'a `audit_chain.verified` olayı yazar; (e) `audit.verify` olmayan hesap eylemleri çağıramaz (403).

⚠ (c) ve (d) spec §7'nin kabul kriteridir ve **ikisi ayrı tabloya** yazar — testin ikisini de ayrı ayrı doğrulaması gerekir, yoksa "loglanıyor" ifadesi hangi tabloya olduğunu söylemeyen bir ifadeye döner.

- [ ] **Adım 2: Testi koş, düştüğünü gör**
- [ ] **Adım 3: Eylemleri yaz** — dört düğme, `canVerify()` gate'i, Livewire ilerleme tazelemesi.
- [ ] **Adım 4: Testi koş**
- [ ] **Adım 5: Pint, analyse, `--group=backoffice`, commit**

---

## Görev 8: Delil paketi

**Dosyalar:**
- Oluştur: `app/Support/Audit/EvidencePackage.php`, `resources/audit-evidence/{BENIOKU.md,dogrula.py,sorgular.sql,yaprak-kurali.md}`
- Test: `tests/Feature/Omurga/EvidencePackageTest.php`

**Arayüzler:**
- Üretir: `EvidencePackage::build(?string $table = null, ?string $from = null, ?string $to = null): string` — üretilen zip'in geçici yolunu döner.

- [ ] **Adım 1: Başarısız testi yaz**

```php
it('packages the seals and the procedure, and no audit row', function (): void {
    // ⛔ Spec §5.2: satırlar kişisel veri taşır; kök kapsadığı şeyi ele vermez.
    // Bu test paketin delil olarak güvenli kalmasını koruyan tek şeydir.
    $path = app(EvidencePackage::class)->build();

    $zip = new ZipArchive;
    $zip->open($path);

    $names = [];
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $names[] = $zip->getNameIndex($i);
    }

    expect($names)->toContain('BENIOKU.md', 'muhurler.json', 'dogrula.py', 'sorgular.sql', 'yaprak-kurali.md', 'tanik.txt');

    $seals = json_decode((string) $zip->getFromName('muhurler.json'), true, flags: JSON_THROW_ON_ERROR);

    foreach ($seals['muhurler'] as $seal) {
        // Yalnız mühür alanları — satır içeriğine ait hiçbir anahtar olmamalı.
        expect(array_keys($seal))->toBe([
            'audit_table', 'epoch_date', 'from_id', 'to_id', 'row_count',
            'merkle_root', 'previous_chain_hash', 'chain_hash',
            'hash_algorithm', 'witness_reference',
        ]);
    }
})->group('omurga');

it('writes the anchor point when a range is packaged', function (): void {
    // ⚠ Spec §5.3: zincir geriye bağlıdır; bir aralığı bağlamsız vermek,
    // zincirin o aralıkta başladığı izlenimi bırakır.
    $path = app(EvidencePackage::class)->build('audit_writes', '2026-08-01', '2026-08-10');

    $zip = new ZipArchive;
    $zip->open($path);
    $seals = json_decode((string) $zip->getFromName('muhurler.json'), true, flags: JSON_THROW_ON_ERROR);

    expect($seals)->toHaveKey('baglanti_noktasi')
        ->and($seals['baglanti_noktasi'])->toHaveKeys(['previous_chain_hash', 'previous_seal_from_id']);
})->group('omurga');
```

- [ ] **Adım 2: Testi koş, düştüğünü gör**
- [ ] **Adım 3: `EvidencePackage`'ı yaz**
- [ ] **Adım 4: Paketin sabit dosyalarını yaz**

`dogrula.py` **iki modda** çalışır (spec §9.2):

| Mod | Bağımlılık | Nasıl |
|---|---|---|
| Doğrudan | `psycopg` | `python dogrula.py --dsn "postgresql://..."` |
| Dosyadan | **yok, yalnız stdlib** | `psql -f sorgular.sql > satirlar.tsv` sonra `python dogrula.py --rows satirlar.tsv` |

⚠ Python 3.12 hedeflenir; yalnız stdlib kullanıldığı sürece 3.9+ uyumluluğu korunur — gereksiz yeni sözdizimi kullanılmaz. `BENIOKU.md` iki modu da adım adım yazar ve hedef sürümü açıkça söyler.

⚠ `yaprak-kurali.md`, `sha256-utc-iso-v2`'yi `2026_07_26_000300_create_audit_merkle_function.php` migration'ındaki fonksiyon gövdesinden **birebir** anlatır: uzunluk öneki (`octet_length`), NULL işareti (`-;`), UTC zaman biçimi (`YYYY-MM-DD"T"HH24:MI:SS.US`). Bir kelime bile uydurulmaz — migration açılır ve okunur.

- [ ] **Adım 5: Paketi gerçekten koştur (spec kabul kriteri 7)**

```bash
cd $(mktemp -d) && unzip /path/to/paket.zip
python3.12 dogrula.py --dsn "$LOCAL_DSN"
```

Beklenen: her mühür için `OK`, sonda `zincir dogrulandi: N muhur`.
⛔ **Bu adım atlanamaz.** Paket testten geçip elde çalışmıyorsa, ürettiğimiz şey bir rapordur, delil değildir.

- [ ] **Adım 6: Pint, analyse, commit**

---

## Görev 9: Yardım makalesi ve kapanış

**Dosyalar:**
- Oluştur: `docs/yardim/audit-zinciri.json`, `public/images/yardim/audit-zinciri.png`
- Değiştir: `database/seeders/HelpArticleSeeder.php`, `changelog/<bugün>.json`, `proje-haritasi.md`

⛔ **Ekran değişirse yardım da değişir** (CLAUDE.md). Yeni bir ofis/backoffice ekranı, kendi makalesini ve **1440px ekran görüntüsünü** getirir.

- [ ] **Adım 1: Makaleyi yaz** — mevcut 27 makalenin biçimini birebir izle (`docs/yardim/` altındaki bir dosyayı aç ve şemasını kopyala).
- [ ] **Adım 2: Ekran görüntüsünü Playwright ile al (1440px)**
- [ ] **Adım 3: `HelpArticleSeeder`'ı koş, makalenin göründüğünü doğrula**
- [ ] **Adım 4: Changelog kaydını yaz** — dikey: `backoffice`. Kullanıcının dilinde: zincirin durumunun görülebilmesi ve dışarıya verilebilir doğrulama paketi.
- [ ] **Adım 5: `proje-haritasi.md`'de EGT-92 satırını "spec yazıldı"dan "✅ yazıldı"ya çevir**
- [ ] **Adım 6: Grupları sırayla koş** — `omurga`, `omurga-rls`, `backoffice`
- [ ] **Adım 7: TeamCity tetikle ve yeşili bekle**

```bash
./deployment/scripts/teamcity.sh trigger
./deployment/scripts/teamcity.sh wait <id>
```

⛔ Yeşil full run olmadan EGT-92 `Fixed`'e çekilmez.

- [ ] **Adım 8: Commit ve ticket kapanışı**

---

## Öz denetim — spec kapsaması

| Spec bölümü | Görev | Not |
|---|---|---|
| §1 Neden var | — | gerekçe, kod üretmez |
| §2 Adres, yetki, menü | 6 | `audit.view_chain` + `audit.verify` |
| §3.1 Dört kart | 4, 6 | `ChainOverview::cards()` |
| §3.2 30 gün şeridi | 4, 6 | `ChainOverview::lastThirtyDays()` |
| §3.3 Mühür listesi | 4, 6 | `ChainOverview::seals()` |
| §3.4 Sağ kolon üç blok | 4, 6 | `SealDetail::for()`; ağaç şemasının "çizim" uyarısı |
| §3.5 Alt şerit | 7 | dört düğme |
| §3.6 Mekanizma sekmesi | 6 | |
| §4 Doğrulama | 5, 7 | tek mühür senkron, tam koşu kuyrukta |
| §5 Delil paketi | 8 | iki düğme, altı dosya, satır yok, bağlantı noktası |
| §6 Kırılma | 1, 2, 3 | bulgu kaydı + Discord + `Log::critical` korunur |
| §6.1 Discord gevşetmesi | 3 | arch testi dördüncü case'i kilitler |
| §7 Loglama | 7 | usage / writes ayrımı ayrı ayrı test edilir |
| §8 Kabul kriterleri 1-10 | 1,3,4,5,6,7,8 | kriter 6 → Görev 5 adım 1(b); kriter 7 → Görev 8 adım 5 |
| §9.1 Bildirim kanalı | 3 | |
| §9.2 Python iki mod | 8 | |
| §9.3 İmza yok, tanık var | 8 | `tanik.txt` |

**Kapsanmayan spec satırı yok.**


---

## Uygulamada ortaya çıkanlar

Plan koda karşı ölçülerek yazıldı ve yine dört şey ancak yazarken/koştururken göründü. Hepsi düzeltildi; buraya yazılmalarının sebebi, bir sonraki ekranın aynı yerlerde tökezlememesi.

**1 · Filament paneli kendi bundle'ını servis eder — utility sınıfları hiçe derlenir.**
Sayfada yazılan `grid`, `rounded-xl`, `text-2xl` **derlenmez**: ekran 200 döner, dört kart alt alta yığılır, tablo düz metin olur ve hiçbir yerde hata çıkmaz. Tarayıcıda görülmeseydi bütün testler yeşil kalırdı. Sayfa artık kendi `<style>` bloğunu ve `ac-` önekli sınıflarını taşıyor. ⚠ Dosyanın kendi yorumunda bu tuzak zaten yazılıydı (03.08.2026, kırpılan tablo) ve dosyanın geri kalanı onu dinlememişti.

**2 · `from_id` DIŞLAYICIDIR.** `app.audit_batch_merkle_root` `WHERE id > p_from_id AND id <= p_to_id` okur. Kapsayıcı yazılsaydı bağımsız doğrulayıcı her mühre fazladan bir satır katar ve hiçbir kök tutmazdı — üstelik paket testi de aynı yanlışı yapacağı için testler geçerdi. Migration açılıp okunmasaydı bulunmazdı.

**3 · "Sağlam" demek için doğrulanmış olmak gerekir.** İlk sürüm son doğrulama zamanını BULGULAR tablosundan okuyordu; o tablo her şey yolundayken boş olduğu için "bir saat önce bakıldı" ile "hiç bakılmadı" aynı görünüyordu ve kart ikisine de "✓ Sağlam" diyordu. `VerifyAuditChain` artık temiz koşuyu da kaydediyor (`audit_chain.verified`).

**4 · Yeni bir izin ve yeni bir denetim olayı üç yere birden yazılır.** Yalnız `config/authorization.php` yetmiyor: `PermissionMatrix` (yoksa izin hiçbir ekranda verilemez ve bunu hiçbir şey söylemez), `lang/{tr,en}/permission.php`, ve olaylar için `lang/{tr,en}/audit.php`. Dördü de arch testiyle kilitli — tam koşu yakaladı.

⚠ Planın **"PermissionTemplateSeeder'a eklenir"** adımı gereksizdi: backoffice süper admin rolü `AuthorizationSeeder` ile her backoffice iznini otomatik alıyor. O seeder ofis şablonları içindir.

## Kapsam dışı çıkan bulgu

**EGT-99** — `.env` ve `.env.example`'da `AUDIT_WITNESS=GitAuditWitness` ve gerçek anchor deposu tanımlı, yani **geliştirme makineleri delil deposuna mühür yayımlıyor**. Depoda 26.07 ve 09.08 tarihli geliştirme mühürleri var ve silinemiyorlar (doğru davranış); lokal veritabanı sıfırlandığı için de 11 bulgu üretiyorlar. Ekran bunu doğru yakaladı — mekanizma çalışıyor, karışan şey veri.
