archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)

- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue),
  Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel
  FAILURE; archive_pending_at nur noch Sammelmarker
- O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre
- O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder
  offener Auftrag, nie ein still unvollständiger Nachweis
- A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore,
  archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut
- O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur
  mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab
- A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold-
  Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit
  compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und
  ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export
- 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id

PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock),
N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für
auslieferbare Bilder), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-02 13:09:01 +00:00
parent f0eeef0144
commit 7cd84b2be3
29 changed files with 2918 additions and 412 deletions

View file

@ -2,48 +2,60 @@
namespace App\Console\Commands;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Models\ArchivePendingJob;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Archive\PendingArchiveRetry;
use Illuminate\Console\Command;
/**
* Beweisarchiv (Umsetzungsaudit 25.09.2026, N11): Meldungen mit gesetztem
* `archive_pending_at` — ihr Archiveintrag ist beim Vorgang fehlgeschlagen
* — erneut archivieren. Läuft täglich; bleibt etwas offen, endet der Lauf
* mit Fehlerstatus, und der Systemstatus zeigt die Zahl.
* Beweisarchiv (N11, Umsetzungsaudit 29.09.2026 A-01/A-02): offene
* Archivaufträge (archive_pending_jobs) je Ereignistyp nachholen. Läuft
* täglich. Ohne gültigen ARCHIVE_KEY bricht der Lauf ab, ohne einen
* Auftrag anzufassen. Bleibt etwas offen, endet er mit Fehlerstatus, und
* der Systemstatus zeigt die Zahl.
*/
class ArchiveRetryPending extends Command
{
protected $signature = 'archive:retry-pending {--dry-run : Nur zählen}';
protected $description = 'Holt fehlgeschlagene Beweisarchiv-Einträge (archive_pending_at) nach';
protected $description = 'Holt ausstehende Beweisarchiv-Einträge (archive_pending_jobs) nach';
public function handle(PendingArchiveRetry $retry): int
public function handle(PendingArchiveRetry $retry, ArchivePendingQueue $queue, EvidenceArchive $archive): int
{
$query = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->orderBy('id');
$pending = $query->count();
if ($this->option('dry-run')) {
$this->info("Offen (Trockenlauf): {$pending} Meldungen mit ausstehender Archivierung.");
$status = $queue->status();
$this->info("Offen (Trockenlauf): {$status['jobs']} Archivaufträge, {$status['legacy_markers']} Altmarker ohne Auftrag.");
return self::SUCCESS;
}
$recorded = 0;
$failed = 0;
if (! $archive->isConfigured()) {
$this->error('ARCHIVE_KEY fehlt oder ist ungültig — nichts nachgeholt, alle Aufträge bleiben offen.');
$query->chunkById(100, function ($pressReleases) use ($retry, &$recorded, &$failed): void {
foreach ($pressReleases as $pressRelease) {
$result = $retry->retry($pressRelease);
$recorded += $result['recorded'];
$failed += $result['failed'];
return self::FAILURE;
}
$queue->adoptLegacyMarkers();
$pending = ArchivePendingJob::query()->count();
$totals = ['recorded' => 0, 'skipped' => 0, 'failed' => 0];
ArchivePendingJob::query()->lazyById(100)->each(function (ArchivePendingJob $job) use ($retry, &$totals): void {
foreach ($retry->retry($job) as $key => $count) {
$totals[$key] += $count;
}
});
$remaining = PressRelease::query()->withoutGlobalScope(PortalScope::class)->withTrashed()->whereNotNull('archive_pending_at')->count();
$this->info(sprintf('Nachgeholt: %d Archiveinträge für %d Meldungen; fehlgeschlagen: %d; weiterhin offen: %d.', $recorded, $pending, $failed, $remaining));
$remaining = ArchivePendingJob::query()->count();
$this->info(sprintf(
'Nachgeholt: %d Archiveinträge aus %d Aufträgen; übersprungen: %d; fehlgeschlagen: %d; weiterhin offen: %d.',
$totals['recorded'],
$pending,
$totals['skipped'],
$totals['failed'],
$remaining,
));
return $failed > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
return $totals['failed'] > 0 || $totals['skipped'] > 0 || $remaining > 0 ? self::FAILURE : self::SUCCESS;
}
}