Spec 075: Verification Checklist Framework V1.5 (fingerprint + acknowledgements) #93

Merged
ahmido merged 2 commits from 075-verification-v1_5 into dev 2026-02-05 21:44:20 +00:00
39 changed files with 3757 additions and 248 deletions
Showing only changes of commit 8eb575ea41 - Show all commits

View File

@ -5,6 +5,8 @@
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
@ -14,6 +16,7 @@
use App\Models\TenantMembership;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\TenantMembershipManager;
@ -21,6 +24,7 @@
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationRegistry;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\VerificationCheckAcknowledgementService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
@ -28,6 +32,7 @@
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Verification\VerificationCheckStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList;
@ -51,6 +56,7 @@
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -286,7 +292,7 @@ public function content(Schema $schema): Schema
SchemaActions::make([
Action::make('wizardStartVerification')
->label('Start verification')
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
->visible(fn (): bool => $this->managedTenant instanceof Tenant && $this->verificationStatus() !== 'in_progress')
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
? null
@ -294,7 +300,7 @@ public function content(Schema $schema): Schema
->action(fn () => $this->startVerification()),
Action::make('wizardRefreshVerification')
->label('Refresh')
->visible(fn (): bool => $this->verificationRunUrl() !== null)
->visible(fn (): bool => $this->verificationRunUrl() !== null && $this->verificationStatus() === 'in_progress')
->action(fn () => $this->refreshVerificationStatus()),
]),
ViewField::make('verification_report')
@ -629,7 +635,22 @@ private function verificationRunUrl(): ?string
}
/**
* @return array{run: array<string, mixed>|null, runUrl: string|null}
* @return array{
* run: array<string, mixed>|null,
* runUrl: string|null,
* report: array<string, mixed>|null,
* fingerprint: string|null,
* changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null,
* previousRunUrl: string|null,
* canAcknowledge: bool,
* acknowledgements: array<string, array{
* check_key: string,
* ack_reason: string,
* acknowledged_at: string|null,
* expires_at: string|null,
* acknowledged_by: array{id: int, name: string}|null
* }>
* }
*/
private function verificationReportViewData(): array
{
@ -640,9 +661,54 @@ private function verificationReportViewData(): array
return [
'run' => null,
'runUrl' => $runUrl,
'report' => null,
'fingerprint' => null,
'changeIndicator' => null,
'previousRunUrl' => null,
'canAcknowledge' => false,
'acknowledgements' => [],
];
}
$report = VerificationReportViewer::report($run);
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
$previousRunUrl = $changeIndicator === null
? null
: $this->tenantlessOperationRunUrl((int) $changeIndicator['previous_report_id']);
$user = auth()->user();
$canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant
? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)
: false;
$acknowledgements = VerificationCheckAcknowledgement::query()
->where('tenant_id', (int) $run->tenant_id)
->where('workspace_id', (int) $run->workspace_id)
->where('operation_run_id', (int) $run->getKey())
->with('acknowledgedByUser')
->get()
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
$user = $ack->acknowledgedByUser;
return [
(string) $ack->check_key => [
'check_key' => (string) $ack->check_key,
'ack_reason' => (string) $ack->ack_reason,
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
'expires_at' => $ack->expires_at?->toJSON(),
'acknowledged_by' => $user instanceof User
? [
'id' => (int) $user->getKey(),
'name' => (string) $user->name,
]
: null,
],
];
})
->all();
$context = is_array($run->context ?? null) ? $run->context : [];
$targetScope = $context['target_scope'] ?? [];
$targetScope = is_array($targetScope) ? $targetScope : [];
@ -650,19 +716,162 @@ private function verificationReportViewData(): array
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
return [
'run' => [
'id' => (int) $run->getKey(),
'type' => (string) $run->type,
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
'initiator_name' => (string) $run->initiator_name,
'started_at' => $run->started_at?->toJSON(),
'completed_at' => $run->completed_at?->toJSON(),
'target_scope' => $targetScope,
'failures' => $failures,
],
'runUrl' => $runUrl,
];
'run' => [
'id' => (int) $run->getKey(),
'type' => (string) $run->type,
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
'initiator_name' => (string) $run->initiator_name,
'started_at' => $run->started_at?->toJSON(),
'completed_at' => $run->completed_at?->toJSON(),
'target_scope' => $targetScope,
'failures' => $failures,
],
'runUrl' => $runUrl,
'report' => $report,
'fingerprint' => $fingerprint,
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'canAcknowledge' => $canAcknowledge,
'acknowledgements' => $acknowledgements,
];
}
public function acknowledgeVerificationCheckAction(): Action
{
return Action::make('acknowledgeVerificationCheck')
->label('Acknowledge')
->color('gray')
->requiresConfirmation()
->modalHeading('Acknowledge issue')
->modalDescription('This records an acknowledgement for governance and audit. It does not change the verification outcome.')
->form([
Textarea::make('ack_reason')
->label('Reason')
->required()
->maxLength(160)
->rows(3),
TextInput::make('expires_at')
->label('Expiry (optional)')
->helperText('Optional timestamp (informational only).')
->maxLength(64),
])
->action(function (array $data, array $arguments): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
throw new NotFoundHttpException;
}
$checkKey = (string) ($arguments['check_key'] ?? '');
$ackReason = (string) ($data['ack_reason'] ?? '');
$expiresAt = $data['expires_at'] ?? null;
$expiresAt = is_string($expiresAt) ? $expiresAt : null;
try {
app(VerificationCheckAcknowledgementService::class)->acknowledge(
tenant: $tenant,
run: $run,
checkKey: $checkKey,
ackReason: $ackReason,
expiresAt: $expiresAt,
actor: $user,
);
} catch (InvalidArgumentException $e) {
Notification::make()
->title('Unable to acknowledge')
->body($e->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Issue acknowledged')
->success()
->send();
})
->visible(function (array $arguments): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (! $this->managedTenant instanceof Tenant) {
return false;
}
if (! $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)) {
return false;
}
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return false;
}
$checkKey = trim((string) ($arguments['check_key'] ?? ''));
if ($checkKey === '') {
return false;
}
$ackExists = VerificationCheckAcknowledgement::query()
->where('operation_run_id', (int) $run->getKey())
->where('check_key', $checkKey)
->exists();
if ($ackExists) {
return false;
}
$report = VerificationReportViewer::report($run);
if (! is_array($report)) {
return false;
}
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
if (($check['key'] ?? null) !== $checkKey) {
continue;
}
$status = $check['status'] ?? null;
return is_string($status) && in_array($status, [
VerificationCheckStatus::Fail->value,
VerificationCheckStatus::Warn->value,
], true);
}
return false;
});
}
private function bootstrapRunsLabel(): string

View File

@ -3,12 +3,16 @@
namespace App\Filament\Resources;
use App\Filament\Resources\OperationRunResource\Pages;
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunDetailPolling;
@ -143,6 +147,63 @@ public static function infolist(Schema $schema): Schema
->label('')
->view('filament.components.verification-report-viewer')
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
->viewData(function (OperationRun $record): array {
$report = VerificationReportViewer::report($record);
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
$previousRunUrl = null;
if ($changeIndicator !== null) {
$tenant = Tenant::current();
$previousRunUrl = $tenant instanceof Tenant
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
}
$acknowledgements = VerificationCheckAcknowledgement::query()
->where('tenant_id', (int) ($record->tenant_id ?? 0))
->where('workspace_id', (int) ($record->workspace_id ?? 0))
->where('operation_run_id', (int) $record->getKey())
->with('acknowledgedByUser')
->get()
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
$user = $ack->acknowledgedByUser;
return [
(string) $ack->check_key => [
'check_key' => (string) $ack->check_key,
'ack_reason' => (string) $ack->ack_reason,
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
'expires_at' => $ack->expires_at?->toJSON(),
'acknowledged_by' => $user instanceof User
? [
'id' => (int) $user->getKey(),
'name' => (string) $user->name,
]
: null,
],
];
})
->all();
return [
'run' => [
'id' => (int) $record->getKey(),
'type' => (string) $record->type,
'status' => (string) $record->status,
'outcome' => (string) $record->outcome,
'started_at' => $record->started_at?->toJSON(),
'completed_at' => $record->completed_at?->toJSON(),
],
'fingerprint' => $fingerprint,
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'acknowledgements' => $acknowledgements,
];
})
->columnSpanFull(),
])
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Support;
use App\Models\OperationRun;
final class VerificationReportChangeIndicator
{
/**
* @return array{state: 'no_changes'|'changed', previous_report_id: int}|null
*/
public static function forRun(OperationRun $run): ?array
{
$report = VerificationReportViewer::report($run);
if ($report === null) {
return null;
}
$previousRun = VerificationReportViewer::previousRun($run, $report);
if ($previousRun === null) {
return null;
}
$previousReport = VerificationReportViewer::report($previousRun);
if ($previousReport === null) {
return null;
}
$currentFingerprint = VerificationReportViewer::fingerprint($report);
$previousFingerprint = VerificationReportViewer::fingerprint($previousReport);
if ($currentFingerprint === null || $previousFingerprint === null) {
return null;
}
return [
'state' => $currentFingerprint === $previousFingerprint ? 'no_changes' : 'changed',
'previous_report_id' => (int) $previousRun->getKey(),
];
}
}

View File

@ -5,6 +5,7 @@
namespace App\Filament\Support;
use App\Models\OperationRun;
use App\Support\Verification\VerificationReportFingerprint;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
@ -31,6 +32,53 @@ public static function report(OperationRun $run): ?array
return $report;
}
public static function previousReportId(array $report): ?int
{
$previousReportId = $report['previous_report_id'] ?? null;
if (is_int($previousReportId) && $previousReportId > 0) {
return $previousReportId;
}
if (is_string($previousReportId) && ctype_digit(trim($previousReportId))) {
return (int) trim($previousReportId);
}
return null;
}
public static function fingerprint(array $report): ?string
{
$fingerprint = $report['fingerprint'] ?? null;
if (is_string($fingerprint)) {
$fingerprint = strtolower(trim($fingerprint));
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
return $fingerprint;
}
}
return VerificationReportFingerprint::forReport($report);
}
public static function previousRun(OperationRun $run, array $report): ?OperationRun
{
$previousReportId = self::previousReportId($report);
if ($previousReportId === null) {
return null;
}
$previous = OperationRun::query()
->whereKey($previousReportId)
->where('tenant_id', (int) $run->tenant_id)
->where('workspace_id', (int) $run->workspace_id)
->first();
return $previous instanceof OperationRun ? $previous : null;
}
public static function shouldRenderForRun(OperationRun $run): bool
{
$context = is_array($run->context) ? $run->context : [];

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class VerificationCheckAcknowledgement extends Model
{
/** @use HasFactory<\Database\Factories\VerificationCheckAcknowledgementFactory> */
use HasFactory;
protected $guarded = [];
protected $casts = [
'expires_at' => 'datetime',
'acknowledged_at' => 'datetime',
];
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
public function operationRun(): BelongsTo
{
return $this->belongsTo(OperationRun::class);
}
public function acknowledgedByUser(): BelongsTo
{
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
}
}

View File

@ -21,6 +21,7 @@ class RoleCapabilityMap
Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE,
@ -44,6 +45,7 @@ class RoleCapabilityMap
Capabilities::TENANT_SYNC,
Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
Capabilities::TENANT_MEMBERSHIP_VIEW,

View File

@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Services\Verification;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Verification\VerificationReportSanitizer;
use App\Support\Verification\VerificationReportSchema;
use App\Support\Verification\VerificationCheckStatus;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Gate;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class VerificationCheckAcknowledgementService
{
public function __construct(
private readonly WorkspaceAuditLogger $audit,
) {}
public function acknowledge(
Tenant $tenant,
OperationRun $run,
string $checkKey,
string $ackReason,
?string $expiresAt,
User $actor,
): VerificationCheckAcknowledgement {
if (! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
Gate::forUser($actor)->authorize(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $tenant);
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ((int) $run->workspace_id !== (int) $tenant->workspace_id) {
throw new NotFoundHttpException;
}
$checkKey = trim($checkKey);
if ($checkKey === '') {
throw new InvalidArgumentException('check_key is required.');
}
$ackReason = trim($ackReason);
if ($ackReason === '') {
throw new InvalidArgumentException('ack_reason is required.');
}
if (mb_strlen($ackReason) > 160) {
throw new InvalidArgumentException('ack_reason must be at most 160 characters.');
}
$report = $this->reportForRun($run);
$check = $this->findCheckByKey($report, $checkKey);
$status = $check['status'] ?? null;
if (! is_string($status) || ! in_array($status, [VerificationCheckStatus::Fail->value, VerificationCheckStatus::Warn->value], true)) {
throw new InvalidArgumentException('Only failing or warning checks can be acknowledged.');
}
$reasonCode = $check['reason_code'] ?? null;
if (! is_string($reasonCode) || trim($reasonCode) === '') {
throw new InvalidArgumentException('Check reason_code is required.');
}
$expiresAtParsed = null;
if ($expiresAt !== null && trim($expiresAt) !== '') {
try {
$expiresAtParsed = CarbonImmutable::parse($expiresAt);
} catch (\Throwable) {
throw new InvalidArgumentException('expires_at must be a valid date-time.');
}
if ($expiresAtParsed->isBefore(CarbonImmutable::now())) {
throw new InvalidArgumentException('expires_at must be in the future.');
}
}
$acknowledgedAt = CarbonImmutable::now();
try {
$ack = VerificationCheckAcknowledgement::create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'operation_run_id' => (int) $run->getKey(),
'check_key' => $checkKey,
'ack_reason' => $ackReason,
'expires_at' => $expiresAtParsed,
'acknowledged_at' => $acknowledgedAt,
'acknowledged_by_user_id' => (int) $actor->getKey(),
]);
} catch (QueryException $e) {
$ack = VerificationCheckAcknowledgement::query()
->where('operation_run_id', (int) $run->getKey())
->where('check_key', $checkKey)
->first();
if (! $ack instanceof VerificationCheckAcknowledgement) {
throw $e;
}
return $ack;
}
if ($ack->wasRecentlyCreated) {
$workspace = $tenant->workspace;
if ($workspace !== null) {
$this->audit->log(
workspace: $workspace,
action: AuditActionId::VerificationCheckAcknowledged->value,
context: [
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => (int) $run->getKey(),
'report_id' => (int) $run->getKey(),
'flow' => (string) $run->type,
'check_key' => $checkKey,
'reason_code' => $reasonCode,
],
actor: $actor,
resourceType: 'operation_run',
resourceId: (string) $run->getKey(),
);
}
}
return $ack;
}
/**
* @return array<string, mixed>
*/
private function reportForRun(OperationRun $run): array
{
$context = is_array($run->context) ? $run->context : [];
$report = $context['verification_report'] ?? null;
if (! is_array($report)) {
throw new InvalidArgumentException('Verification report is missing.');
}
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
throw new InvalidArgumentException('Verification report is invalid.');
}
return $report;
}
/**
* @param array<string, mixed> $report
* @return array<string, mixed>
*/
private function findCheckByKey(array $report, string $checkKey): array
{
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
if (($check['key'] ?? null) === $checkKey) {
return $check;
}
}
throw new InvalidArgumentException('Check not found in verification report.');
}
}

View File

@ -29,4 +29,5 @@ enum AuditActionId: string
case ManagedTenantOnboardingVerificationStart = 'managed_tenant_onboarding.verification_start';
case ManagedTenantOnboardingActivation = 'managed_tenant_onboarding.activation';
case VerificationCompleted = 'verification.completed';
case VerificationCheckAcknowledged = 'verification.check_acknowledged';
}

View File

@ -57,7 +57,7 @@ private static function sanitizeString(string $value): string
return self::REDACTED;
}
if (preg_match('/\b[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\b/', $candidate)) {
if (preg_match('/\b[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\.[A-Za-z0-9\-_]{20,}\b/', $candidate)) {
return self::REDACTED;
}

View File

@ -61,6 +61,9 @@ class Capabilities
// Findings
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
// Verification
public const TENANT_VERIFICATION_ACKNOWLEDGE = 'tenant_verification.acknowledge';
// Tenant memberships
public const TENANT_MEMBERSHIP_VIEW = 'tenant_membership.view';

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
use App\Models\OperationRun;
use App\Support\OperationRunStatus;
final class PreviousVerificationReportResolver
{
public static function resolvePreviousReportId(OperationRun $run): ?int
{
$runId = $run->getKey();
if (! is_int($runId) || $runId <= 0) {
return null;
}
$providerConnectionId = self::providerConnectionId($run);
$query = OperationRun::query()
->where('tenant_id', (int) $run->tenant_id)
->where('workspace_id', (int) $run->workspace_id)
->where('type', (string) $run->type)
->where('run_identity_hash', (string) $run->run_identity_hash)
->where('status', OperationRunStatus::Completed->value)
->where('id', '<', $runId)
->orderByDesc('id');
if ($providerConnectionId !== null) {
$query->where('context->provider_connection_id', $providerConnectionId);
} else {
$query->whereNull('context->provider_connection_id');
}
$previousId = $query->value('id');
return is_int($previousId) ? $previousId : null;
}
private static function providerConnectionId(OperationRun $run): ?int
{
$context = $run->context;
if (! is_array($context)) {
return null;
}
$providerConnectionId = $context['provider_connection_id'] ?? null;
if (is_int($providerConnectionId)) {
return $providerConnectionId;
}
if (is_string($providerConnectionId) && ctype_digit(trim($providerConnectionId))) {
return (int) trim($providerConnectionId);
}
return null;
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Support\Verification;
final class VerificationReportFingerprint
{
/**
* @param array<int, array<string, mixed>> $checks
*/
public static function forChecks(array $checks): string
{
$tuples = [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
$key = self::normalizeKey($check['key'] ?? null);
$status = self::normalizeEnumString($check['status'] ?? null);
$reasonCode = self::normalizeEnumString($check['reason_code'] ?? null);
$blocking = is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false;
$severity = $check['severity'] ?? null;
$severity = is_string($severity) ? trim($severity) : '';
if ($severity === '') {
$severity = '';
} else {
$severity = strtolower($severity);
}
$tuples[] = [
'key' => $key,
'tuple' => implode('|', [
$key,
$status,
$blocking ? '1' : '0',
$reasonCode,
$severity,
]),
];
}
usort($tuples, static function (array $a, array $b): int {
$keyComparison = $a['key'] <=> $b['key'];
if ($keyComparison !== 0) {
return $keyComparison;
}
return $a['tuple'] <=> $b['tuple'];
});
$payload = implode("\n", array_map(static fn (array $item): string => (string) $item['tuple'], $tuples));
return hash('sha256', $payload);
}
/**
* @param array<string, mixed> $report
*/
public static function forReport(array $report): string
{
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
/** @var array<int, array<string, mixed>> $checks */
return self::forChecks($checks);
}
private static function normalizeKey(mixed $value): string
{
if (! is_string($value)) {
return '';
}
$value = trim($value);
return $value === '' ? '' : $value;
}
private static function normalizeEnumString(mixed $value): string
{
if (! is_string($value)) {
return '';
}
$value = trim($value);
return $value === '' ? '' : strtolower($value);
}
}

View File

@ -39,6 +39,40 @@ public static function sanitizeReport(array $report): array
$sanitized['generated_at'] = $generatedAt;
}
if (array_key_exists('fingerprint', $report)) {
$fingerprint = $report['fingerprint'];
if (is_string($fingerprint)) {
$fingerprint = strtolower(trim($fingerprint));
if (preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
$sanitized['fingerprint'] = $fingerprint;
}
}
}
if (array_key_exists('previous_report_id', $report)) {
$previousReportId = $report['previous_report_id'];
if ($previousReportId === null || is_int($previousReportId)) {
$sanitized['previous_report_id'] = $previousReportId;
} elseif (is_string($previousReportId)) {
$previousReportId = trim($previousReportId);
if ($previousReportId === '') {
$sanitized['previous_report_id'] = null;
} elseif (ctype_digit($previousReportId)) {
$sanitized['previous_report_id'] = (int) $previousReportId;
} else {
$previousReportId = self::sanitizeShortString($previousReportId, fallback: null);
if ($previousReportId !== null) {
$sanitized['previous_report_id'] = $previousReportId;
}
}
}
}
if (is_array($report['identity'] ?? null)) {
$identity = self::sanitizeIdentity((array) $report['identity']);
@ -164,8 +198,14 @@ private static function sanitizeChecks(array $checks): ?array
continue;
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
$severityRaw = $check['severity'] ?? null;
if (! is_string($severityRaw)) {
continue;
}
$severity = strtolower(trim($severityRaw));
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
continue;
}

View File

@ -6,7 +6,7 @@
final class VerificationReportSchema
{
public const string CURRENT_SCHEMA_VERSION = '1.0.0';
public const string CURRENT_SCHEMA_VERSION = '1.5.0';
/**
* @return array<string, mixed>|null
@ -78,6 +78,22 @@ public static function isValidReport(array $report): bool
}
}
if (array_key_exists('fingerprint', $report)) {
$fingerprint = $report['fingerprint'];
if (! is_string($fingerprint) || ! preg_match('/^[a-f0-9]{64}$/', $fingerprint)) {
return false;
}
}
if (array_key_exists('previous_report_id', $report)) {
$previousReportId = $report['previous_report_id'];
if ($previousReportId !== null && ! is_int($previousReportId) && ! self::isNonEmptyString($previousReportId)) {
return false;
}
}
return true;
}
@ -137,7 +153,13 @@ private static function isValidCheckResult(array $check): bool
}
$severity = $check['severity'] ?? null;
if (! is_string($severity) || ! in_array($severity, VerificationCheckSeverity::values(), true)) {
if (! is_string($severity)) {
return false;
}
$severity = trim($severity);
if ($severity !== '' && ! in_array($severity, VerificationCheckSeverity::values(), true)) {
return false;
}

View File

@ -35,6 +35,8 @@ public static function write(OperationRun $run, array $checks, array $identity =
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
$report = self::build($flow, $checks, $identity);
$report['previous_report_id'] = PreviousVerificationReportResolver::resolvePreviousReportId($run);
$report = VerificationReportSanitizer::sanitizeReport($report);
if (! VerificationReportSchema::isValidReport($report)) {
@ -75,6 +77,8 @@ public static function build(string $flow, array $checks, array $identity = []):
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow,
'generated_at' => now()->toISOString(),
'fingerprint' => VerificationReportFingerprint::forChecks($normalizedChecks),
'previous_report_id' => null,
'summary' => [
'overall' => self::deriveOverall($normalizedChecks, $counts),
'counts' => $counts,
@ -98,6 +102,8 @@ private static function buildFallbackReport(string $flow): array
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
'flow' => $flow !== '' ? $flow : 'unknown',
'generated_at' => now()->toISOString(),
'fingerprint' => VerificationReportFingerprint::forChecks([]),
'previous_report_id' => null,
'summary' => [
'overall' => VerificationReportOverall::NeedsAttention->value,
'counts' => [
@ -161,14 +167,12 @@ private static function normalizeCheckStatus(mixed $status): string
private static function normalizeCheckSeverity(mixed $severity): string
{
if (! is_string($severity)) {
return VerificationCheckSeverity::Info->value;
return '';
}
$severity = strtolower(trim($severity));
return in_array($severity, VerificationCheckSeverity::values(), true)
? $severity
: VerificationCheckSeverity::Info->value;
return in_array($severity, VerificationCheckSeverity::values(), true) ? $severity : '';
}
private static function normalizeReasonCode(mixed $reasonCode): string

View File

@ -0,0 +1,37 @@
<?php
namespace Database\Factories;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<VerificationCheckAcknowledgement>
*/
class VerificationCheckAcknowledgementFactory extends Factory
{
protected $model = VerificationCheckAcknowledgement::class;
public function definition(): array
{
return [
'operation_run_id' => function (): int {
return (int) OperationRun::factory()->create()->getKey();
},
'tenant_id' => function (array $attributes): int {
return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('tenant_id');
},
'workspace_id' => function (array $attributes): int {
return (int) OperationRun::query()->whereKey((int) $attributes['operation_run_id'])->value('workspace_id');
},
'check_key' => 'provider_connection.token_acquisition',
'ack_reason' => fake()->sentence(6),
'expires_at' => null,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => User::factory(),
];
}
}

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('verification_check_acknowledgements', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
$table->foreignId('operation_run_id')->constrained('operation_runs')->cascadeOnDelete();
$table->string('check_key');
$table->string('ack_reason', 160);
$table->timestampTz('expires_at')->nullable();
$table->timestampTz('acknowledged_at');
$table->foreignId('acknowledged_by_user_id')->constrained('users');
$table->timestamps();
$table->unique(['operation_run_id', 'check_key']);
$table->index(['tenant_id', 'workspace_id', 'operation_run_id']);
$table->index(['operation_run_id']);
});
}
public function down(): void
{
Schema::dropIfExists('verification_check_acknowledgements');
}
};

View File

@ -20,6 +20,7 @@
<php>
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/>
<env name="APP_KEY" value="base64:z63PQuXp3rUOQ0L4o8xp76xeakrn5X3owja1qFX3ccY="/>
<env name="INTUNE_TENANT_ID" value="" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>

View File

@ -2,14 +2,95 @@
$report = isset($getState) ? $getState() : ($report ?? null);
$report = is_array($report) ? $report : null;
$run = $run ?? null;
$run = is_array($run) ? $run : null;
$fingerprint = $fingerprint ?? null;
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
$changeIndicator = $changeIndicator ?? null;
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
$previousRunUrl = $previousRunUrl ?? null;
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
$acknowledgements = $acknowledgements ?? [];
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$counts = $summary['counts'] ?? null;
$counts = is_array($counts) ? $counts : [];
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
$ackByKey = [];
foreach ($acknowledgements as $checkKey => $ack) {
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
continue;
}
$ackByKey[$checkKey] = $ack;
}
$blockers = [];
$failures = [];
$warnings = [];
$acknowledgedIssues = [];
$passed = [];
foreach ($checks as $check) {
$check = is_array($check) ? $check : [];
$key = $check['key'] ?? null;
$key = is_string($key) ? trim($key) : '';
if ($key === '') {
continue;
}
$statusValue = $check['status'] ?? null;
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
if (array_key_exists($key, $ackByKey)) {
$acknowledgedIssues[] = $check;
continue;
}
if ($statusValue === 'pass') {
$passed[] = $check;
continue;
}
if ($statusValue === 'fail' && $blocking) {
$blockers[] = $check;
continue;
}
if ($statusValue === 'fail') {
$failures[] = $check;
continue;
}
if ($statusValue === 'warn') {
$warnings[] = $check;
}
}
$sortChecks = static function (array $a, array $b): int {
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
};
usort($blockers, $sortChecks);
usort($failures, $sortChecks);
usort($warnings, $sortChecks);
usort($acknowledgedIssues, $sortChecks);
usort($passed, $sortChecks);
@endphp
<div class="space-y-4">
@ -21,6 +102,9 @@
<div class="mt-1">
This run doesnt have a report yet. If its still running, refresh in a moment. If it already completed, start verification again.
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
</div>
@else
@php
@ -30,149 +114,377 @@
);
@endphp
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-center gap-2">
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['total'] ?? 0) }} total
</x-filament::badge>
<x-filament::badge color="success">
{{ (int) ($counts['pass'] ?? 0) }} pass
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($counts['fail'] ?? 0) }} fail
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($counts['warn'] ?? 0) }} warn
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['skip'] ?? 0) }} skip
</x-filament::badge>
<x-filament::badge color="info">
{{ (int) ($counts['running'] ?? 0) }} running
</x-filament::badge>
</div>
<x-filament::badge color="gray">
{{ (int) ($counts['total'] ?? 0) }} total
</x-filament::badge>
<x-filament::badge color="success">
{{ (int) ($counts['pass'] ?? 0) }} pass
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($counts['fail'] ?? 0) }} fail
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($counts['warn'] ?? 0) }} warn
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['skip'] ?? 0) }} skip
</x-filament::badge>
<x-filament::badge color="info">
{{ (int) ($counts['running'] ?? 0) }} running
</x-filament::badge>
@if ($checks === [])
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
No checks found in this report. Start verification again to generate a fresh report.
</div>
@else
<div class="space-y-3">
@foreach ($checks as $check)
@if ($changeIndicator !== null)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? $title : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? $message : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$evidence = $check['evidence'] ?? [];
$evidence = is_array($evidence) ? $evidence : [];
$nextSteps = $check['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
$state = $changeIndicator['state'] ?? null;
$state = is_string($state) ? $state : null;
@endphp
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="flex cursor-pointer items-start justify-between gap-4">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
@if ($state === 'no_changes')
<x-filament::badge color="success">
No changes since previous verification
</x-filament::badge>
@elseif ($state === 'changed')
<x-filament::badge color="warning">
Changed since previous verification
</x-filament::badge>
@endif
@endif
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
</div>
<div x-data="{ tab: 'issues' }" class="space-y-4">
<x-filament::tabs label="Verification report tabs">
<x-filament::tabs.item
:active="true"
alpine-active="tab === 'issues'"
x-on:click="tab = 'issues'"
>
Issues
</x-filament::tabs.item>
<x-filament::tabs.item
:active="false"
alpine-active="tab === 'passed'"
x-on:click="tab = 'passed'"
>
Passed
</x-filament::tabs.item>
<x-filament::tabs.item
:active="false"
alpine-active="tab === 'technical'"
x-on:click="tab = 'technical'"
>
Technical details
</x-filament::tabs.item>
</x-filament::tabs>
<div x-show="tab === 'issues'">
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
<div class="text-sm text-gray-700 dark:text-gray-200">
No issues found in this report.
</div>
@else
<div class="space-y-3">
@php
$issueGroups = [
['label' => 'Blockers', 'checks' => $blockers],
['label' => 'Failures', 'checks' => $failures],
['label' => 'Warnings', 'checks' => $warnings],
];
@endphp
@foreach ($issueGroups as $group)
@php
$label = $group['label'];
$groupChecks = $group['checks'];
@endphp
@if ($groupChecks !== [])
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $label }}
</div>
<div class="space-y-2">
@foreach ($groupChecks as $check)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$nextSteps = $check['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
@if ($blocking)
<x-filament::badge color="danger" size="sm">
Blocker
</x-filament::badge>
@endif
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</div>
@if ($nextSteps !== [])
<div class="mt-4">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
@endphp
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
<li>
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($isExternal)
target="_blank" rel="noreferrer"
@endif
>
{{ $label }}
</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@endforeach
@if ($acknowledgedIssues !== [])
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
Acknowledged issues
</summary>
<div class="mt-4 space-y-2">
@foreach ($acknowledgedIssues as $check)
@php
$check = is_array($check) ? $check : [];
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
$ack = is_array($ack) ? $ack : null;
$ackReason = $ack['ack_reason'] ?? null;
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
$ackAt = $ack['acknowledged_at'] ?? null;
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
$ackBy = $ack['acknowledged_by'] ?? null;
$ackBy = is_array($ackBy) ? $ackBy : null;
$ackByName = $ackBy['name'] ?? null;
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
$expiresAt = $ack['expires_at'] ?? null;
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
@endphp
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</div>
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@if ($ackReason)
<div>
<span class="font-semibold">Reason:</span> {{ $ackReason }}
</div>
@endif
@if ($ackByName || $ackAt)
<div>
<span class="font-semibold">Acknowledged:</span>
@if ($ackByName)
{{ $ackByName }}
@endif
@if ($ackAt)
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
@endif
</div>
@endif
@if ($expiresAt)
<div>
<span class="font-semibold">Expires:</span>
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
</div>
@endif
</div>
<div x-show="tab === 'passed'" style="display: none;">
@if ($passed === [])
<div class="text-sm text-gray-600 dark:text-gray-300">
No passing checks recorded.
</div>
@else
<div class="space-y-2">
@foreach ($passed as $check)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
@endphp
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</summary>
@if ($evidence !== [] || $nextSteps !== [])
<div class="mt-4 space-y-4">
@if ($evidence !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Evidence
</div>
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@foreach ($evidence as $pointer)
@php
$pointer = is_array($pointer) ? $pointer : [];
$kind = $pointer['kind'] ?? null;
$value = $pointer['value'] ?? null;
@endphp
@if (is_string($kind) && $kind !== '' && (is_string($value) || is_int($value)))
<li>
<span class="font-medium">{{ $kind }}:</span>
<span>{{ is_int($value) ? $value : $value }}</span>
</li>
@endif
@endforeach
</ul>
</div>
@endif
@if ($nextSteps !== [])
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
@endphp
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
<li>
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($isExternal)
target="_blank" rel="noreferrer"
@endif
>
{{ $label }}
</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
</div>
@endif
</details>
@endforeach
@endforeach
</div>
@endif
</div>
@endif
<div x-show="tab === 'technical'" style="display: none;">
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Identifiers
</div>
<div class="flex flex-col gap-1">
@if ($run !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
</div>
@endif
@if ($fingerprint)
<div>
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
</div>
@endif
</div>
</div>
@if ($previousRunUrl !== null)
<div>
<a
href="{{ $previousRunUrl }}"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Open previous verification
</a>
</div>
@endif
</div>
</div>
</div>
@endif
</div>

View File

@ -7,6 +7,23 @@
$runUrl = $runUrl ?? null;
$runUrl = is_string($runUrl) && $runUrl !== '' ? $runUrl : null;
$report = $report ?? null;
$report = is_array($report) ? $report : null;
$fingerprint = $fingerprint ?? null;
$fingerprint = is_string($fingerprint) && trim($fingerprint) !== '' ? trim($fingerprint) : null;
$changeIndicator = $changeIndicator ?? null;
$changeIndicator = is_array($changeIndicator) ? $changeIndicator : null;
$previousRunUrl = $previousRunUrl ?? null;
$previousRunUrl = is_string($previousRunUrl) && $previousRunUrl !== '' ? $previousRunUrl : null;
$canAcknowledge = (bool) ($canAcknowledge ?? false);
$acknowledgements = $acknowledgements ?? [];
$acknowledgements = is_array($acknowledgements) ? $acknowledgements : [];
$status = $run['status'] ?? null;
$status = is_string($status) ? $status : null;
@ -31,6 +48,87 @@
$completedAtLabel = $completedAt;
}
}
$summary = $report['summary'] ?? null;
$summary = is_array($summary) ? $summary : null;
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : [];
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
$ackByKey = [];
foreach ($acknowledgements as $checkKey => $ack) {
if (! is_string($checkKey) || $checkKey === '' || ! is_array($ack)) {
continue;
}
$ackByKey[$checkKey] = $ack;
}
$blockers = [];
$failures = [];
$warnings = [];
$acknowledgedIssues = [];
$passed = [];
foreach ($checks as $check) {
$check = is_array($check) ? $check : [];
$key = $check['key'] ?? null;
$key = is_string($key) ? trim($key) : '';
if ($key === '') {
continue;
}
$statusValue = $check['status'] ?? null;
$statusValue = is_string($statusValue) ? strtolower(trim($statusValue)) : '';
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
if (array_key_exists($key, $ackByKey)) {
$acknowledgedIssues[] = $check;
continue;
}
if ($statusValue === 'pass') {
$passed[] = $check;
continue;
}
if ($statusValue === 'fail' && $blocking) {
$blockers[] = $check;
continue;
}
if ($statusValue === 'fail') {
$failures[] = $check;
continue;
}
if ($statusValue === 'warn') {
$warnings[] = $check;
}
}
$sortChecks = static function (array $a, array $b): int {
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
};
usort($blockers, $sortChecks);
usort($failures, $sortChecks);
usort($warnings, $sortChecks);
usort($acknowledgedIssues, $sortChecks);
usort($passed, $sortChecks);
$ackAction = null;
if (isset($this) && method_exists($this, 'acknowledgeVerificationCheckAction')) {
$ackAction = $this->acknowledgeVerificationCheckAction();
}
@endphp
<x-dynamic-component :component="$fieldWrapperView" :field="$field">
@ -47,88 +145,450 @@
<div class="text-sm text-gray-600 dark:text-gray-300">
Report unavailable while the run is in progress. Use “Refresh” to re-check stored status.
</div>
@elseif ($outcome === 'succeeded')
<div class="text-sm text-gray-700 dark:text-gray-200">
All verification checks passed.
</div>
@elseif ($failures === [])
<div class="text-sm text-gray-600 dark:text-gray-300">
Report unavailable. The run completed, but no failure details were recorded.
</div>
@else
<div class="space-y-3">
<div class="text-sm font-medium text-gray-900 dark:text-white">
Findings
</div>
<ul class="space-y-2 text-sm text-gray-700 dark:text-gray-200">
@foreach ($failures as $failure)
@php
$reasonCode = is_array($failure) ? ($failure['reason_code'] ?? null) : null;
$message = is_array($failure) ? ($failure['message'] ?? null) : null;
$reasonCode = is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
$message = is_string($message) && $message !== '' ? $message : null;
@endphp
@if ($reasonCode !== null || $message !== null)
<li class="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-800 dark:bg-gray-900">
@if ($reasonCode !== null)
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $reasonCode }}
</div>
@endif
@if ($message !== null)
<div class="mt-1 text-sm text-gray-700 dark:text-gray-200">
{{ $message }}
</div>
@endif
</li>
@endif
@endforeach
</ul>
</div>
@endif
@if ($targetScope !== [])
<div class="mt-4">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Target scope
</div>
<div class="mt-2 flex flex-col gap-1 text-sm text-gray-700 dark:text-gray-200">
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
@php
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
$overallSpec = $summary === null
? null
: \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationReportOverall,
$summary['overall'] ?? null,
);
@endphp
@if ($entraTenantName !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
</div>
@endif
<div class="flex flex-wrap items-center gap-2">
@if ($overallSpec)
<x-filament::badge :color="$overallSpec->color" :icon="$overallSpec->icon">
{{ $overallSpec->label }}
</x-filament::badge>
@endif
@if ($entraTenantId !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
</div>
@endif
<x-filament::badge color="gray">
{{ (int) ($counts['total'] ?? 0) }} total
</x-filament::badge>
<x-filament::badge color="success">
{{ (int) ($counts['pass'] ?? 0) }} pass
</x-filament::badge>
<x-filament::badge color="danger">
{{ (int) ($counts['fail'] ?? 0) }} fail
</x-filament::badge>
<x-filament::badge color="warning">
{{ (int) ($counts['warn'] ?? 0) }} warn
</x-filament::badge>
<x-filament::badge color="gray">
{{ (int) ($counts['skip'] ?? 0) }} skip
</x-filament::badge>
<x-filament::badge color="info">
{{ (int) ($counts['running'] ?? 0) }} running
</x-filament::badge>
@if ($changeIndicator !== null)
@php
$state = $changeIndicator['state'] ?? null;
$state = is_string($state) ? $state : null;
@endphp
@if ($state === 'no_changes')
<x-filament::badge color="success">
No changes since previous verification
</x-filament::badge>
@elseif ($state === 'changed')
<x-filament::badge color="warning">
Changed since previous verification
</x-filament::badge>
@endif
@endif
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<span class="font-semibold">Read-only:</span> this view uses stored data and makes no external calls.
</div>
</div>
</div>
@endif
@if ($runUrl !== null)
<div class="mt-4">
<a
href="{{ $runUrl }}"
class="text-sm font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Open run details
</a>
@if ($report === null || $summary === null)
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 shadow-sm dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
<div class="font-medium text-gray-900 dark:text-white">
Verification report unavailable
</div>
<div class="mt-1">
This run doesnt have a report yet. If it already completed, start verification again.
</div>
</div>
@else
<div
x-data="{ tab: 'issues' }"
class="space-y-4"
>
<x-filament::tabs label="Verification report tabs">
<x-filament::tabs.item
:active="true"
alpine-active="tab === 'issues'"
x-on:click="tab = 'issues'"
>
Issues
</x-filament::tabs.item>
<x-filament::tabs.item
:active="false"
alpine-active="tab === 'passed'"
x-on:click="tab = 'passed'"
>
Passed
</x-filament::tabs.item>
<x-filament::tabs.item
:active="false"
alpine-active="tab === 'technical'"
x-on:click="tab = 'technical'"
>
Technical details
</x-filament::tabs.item>
</x-filament::tabs>
<div x-show="tab === 'issues'">
@if ($blockers === [] && $failures === [] && $warnings === [] && $acknowledgedIssues === [])
<div class="text-sm text-gray-700 dark:text-gray-200">
No issues found in this report.
</div>
@else
<div class="space-y-3">
@php
$issueGroups = [
['label' => 'Blockers', 'checks' => $blockers],
['label' => 'Failures', 'checks' => $failures],
['label' => 'Warnings', 'checks' => $warnings],
];
@endphp
@foreach ($issueGroups as $group)
@php
$label = $group['label'];
$groupChecks = $group['checks'];
@endphp
@if ($groupChecks !== [])
<div class="space-y-2">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $label }}
</div>
<div class="space-y-2">
@foreach ($groupChecks as $check)
@php
$check = is_array($check) ? $check : [];
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$nextSteps = $check['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? array_slice($nextSteps, 0, 2) : [];
$blocking = $check['blocking'] ?? false;
$blocking = is_bool($blocking) ? $blocking : false;
@endphp
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
@if ($blocking)
<x-filament::badge color="danger" size="sm">
Blocker
</x-filament::badge>
@endif
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
@if ($ackAction !== null && $canAcknowledge && $checkKey !== '')
{{ ($ackAction)(['check_key' => $checkKey]) }}
@endif
</div>
</div>
@if ($nextSteps !== [])
<div class="mt-4">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Next steps
</div>
<ul class="mt-2 space-y-1 text-sm">
@foreach ($nextSteps as $step)
@php
$step = is_array($step) ? $step : [];
$label = $step['label'] ?? null;
$url = $step['url'] ?? null;
$isExternal = is_string($url) && (str_starts_with($url, 'http://') || str_starts_with($url, 'https://'));
@endphp
@if (is_string($label) && $label !== '' && is_string($url) && $url !== '')
<li>
<a
href="{{ $url }}"
class="text-primary-600 hover:underline dark:text-primary-400"
@if ($isExternal)
target="_blank" rel="noreferrer"
@endif
>
{{ $label }}
</a>
</li>
@endif
@endforeach
</ul>
</div>
@endif
</div>
@endforeach
</div>
</div>
@endif
@endforeach
@if ($acknowledgedIssues !== [])
<details class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<summary class="cursor-pointer text-sm font-semibold text-gray-900 dark:text-white">
Acknowledged issues
</summary>
<div class="mt-4 space-y-2">
@foreach ($acknowledgedIssues as $check)
@php
$check = is_array($check) ? $check : [];
$checkKey = is_string($check['key'] ?? null) ? trim((string) $check['key']) : '';
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$message = $check['message'] ?? null;
$message = is_string($message) && trim($message) !== '' ? trim($message) : null;
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
$severitySpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckSeverity,
$check['severity'] ?? null,
);
$ack = $checkKey !== '' ? ($ackByKey[$checkKey] ?? null) : null;
$ack = is_array($ack) ? $ack : null;
$ackReason = $ack['ack_reason'] ?? null;
$ackReason = is_string($ackReason) && trim($ackReason) !== '' ? trim($ackReason) : null;
$ackAt = $ack['acknowledged_at'] ?? null;
$ackAt = is_string($ackAt) && trim($ackAt) !== '' ? trim($ackAt) : null;
$ackBy = $ack['acknowledged_by'] ?? null;
$ackBy = is_array($ackBy) ? $ackBy : null;
$ackByName = $ackBy['name'] ?? null;
$ackByName = is_string($ackByName) && trim($ackByName) !== '' ? trim($ackByName) : null;
$expiresAt = $ack['expires_at'] ?? null;
$expiresAt = is_string($expiresAt) && trim($expiresAt) !== '' ? trim($expiresAt) : null;
@endphp
<div class="rounded-lg border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
@if ($message)
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $message }}
</div>
@endif
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<x-filament::badge :color="$severitySpec->color" :icon="$severitySpec->icon" size="sm">
{{ $severitySpec->label }}
</x-filament::badge>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
</div>
@if ($ackReason || $ackAt || $ackByName || $expiresAt)
<div class="mt-3 space-y-1 text-sm text-gray-700 dark:text-gray-200">
@if ($ackReason)
<div>
<span class="font-semibold">Reason:</span> {{ $ackReason }}
</div>
@endif
@if ($ackByName || $ackAt)
<div>
<span class="font-semibold">Acknowledged:</span>
@if ($ackByName)
{{ $ackByName }}
@endif
@if ($ackAt)
<span class="text-gray-500 dark:text-gray-400">({{ $ackAt }})</span>
@endif
</div>
@endif
@if ($expiresAt)
<div>
<span class="font-semibold">Expires:</span>
<span class="text-gray-500 dark:text-gray-400">{{ $expiresAt }}</span>
</div>
@endif
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
</div>
@endif
</div>
<div x-show="tab === 'passed'" style="display: none;">
@if ($passed === [])
<div class="text-sm text-gray-600 dark:text-gray-300">
No passing checks recorded.
</div>
@else
<div class="space-y-2">
@foreach ($passed as $check)
@php
$check = is_array($check) ? $check : [];
$title = $check['title'] ?? 'Check';
$title = is_string($title) && trim($title) !== '' ? trim($title) : 'Check';
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::VerificationCheckStatus,
$check['status'] ?? null,
);
@endphp
<div class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="font-medium text-gray-900 dark:text-white">
{{ $title }}
</div>
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
</div>
@endforeach
</div>
@endif
</div>
<div x-show="tab === 'technical'" style="display: none;">
<div class="space-y-4 text-sm text-gray-700 dark:text-gray-200">
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Identifiers
</div>
<div class="flex flex-col gap-1">
<div>
<span class="text-gray-500 dark:text-gray-400">Run ID:</span>
<span class="font-mono">{{ (int) ($run['id'] ?? 0) }}</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Flow:</span>
<span class="font-mono">{{ (string) ($run['type'] ?? '') }}</span>
</div>
@if ($fingerprint)
<div>
<span class="text-gray-500 dark:text-gray-400">Fingerprint:</span>
<span class="font-mono text-xs break-all">{{ $fingerprint }}</span>
</div>
@endif
</div>
</div>
@if ($previousRunUrl !== null)
<div>
<a
href="{{ $previousRunUrl }}"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Open previous verification
</a>
</div>
@endif
@if ($runUrl !== null)
<div>
<a
href="{{ $runUrl }}"
class="font-medium text-primary-600 hover:underline dark:text-primary-400"
>
Open run details
</a>
</div>
@endif
@if ($targetScope !== [])
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Target scope
</div>
<div class="flex flex-col gap-1">
@php
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
$entraTenantId = is_string($entraTenantId) && $entraTenantId !== '' ? $entraTenantId : null;
$entraTenantName = is_string($entraTenantName) && $entraTenantName !== '' ? $entraTenantName : null;
@endphp
@if ($entraTenantName !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Entra tenant:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ $entraTenantName }}</span>
</div>
@endif
@if ($entraTenantId !== null)
<div>
<span class="text-gray-500 dark:text-gray-400">Entra tenant ID:</span>
<span class="font-mono text-xs break-all text-gray-900 dark:text-gray-100">{{ $entraTenantId }}</span>
</div>
@endif
</div>
</div>
@endif
</div>
</div>
</div>
@endif
</div>
@endif
</x-filament::section>

View File

@ -0,0 +1,34 @@
# Specification Quality Checklist: Verification Checklist Framework V1.5
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-02-05
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Reviewed against template used in [specs/074-verification-checklist/spec.md](../../074-verification-checklist/spec.md). No open clarifications remain.

View File

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/acknowledge-check.request.schema.json",
"title": "AcknowledgeVerificationCheckRequest",
"type": "object",
"additionalProperties": false,
"required": [
"report_id",
"check_key",
"ack_reason"
],
"properties": {
"report_id": {
"type": ["string", "integer"]
},
"check_key": {
"type": "string",
"minLength": 1
},
"ack_reason": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"expires_at": {
"description": "Optional informational expiry timestamp.",
"type": ["string", "null"],
"format": "date-time"
}
}
}

View File

@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/verification-check-acknowledgement.schema.json",
"title": "VerificationCheckAcknowledgement",
"type": "object",
"additionalProperties": false,
"required": [
"report_id",
"check_key",
"ack_reason",
"acknowledged_at",
"acknowledged_by"
],
"properties": {
"report_id": {
"description": "OperationRun id that contains the report.",
"type": ["string", "integer"]
},
"check_key": {
"type": "string",
"minLength": 1
},
"ack_reason": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"expires_at": {
"description": "Informational only in v1.5.",
"type": ["string", "null"],
"format": "date-time"
},
"acknowledged_at": {
"type": "string",
"format": "date-time"
},
"acknowledged_by": {
"type": "object",
"additionalProperties": false,
"required": ["id"],
"properties": {
"id": { "type": ["string", "integer"] },
"name": { "type": ["string", "null"] },
"email": { "type": ["string", "null"] }
}
}
}
}

View File

@ -0,0 +1,147 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantpilot.local/contracts/verification-report.v1_5.schema.json",
"title": "VerificationReportV1_5",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"flow",
"generated_at",
"fingerprint",
"previous_report_id",
"summary",
"checks"
],
"properties": {
"report_id": {
"description": "Canonical report identifier. In v1.5 this is the OperationRun id.",
"type": ["string", "integer"]
},
"schema_version": {
"type": "string",
"description": "Version of the verification report schema (SemVer, major 1).",
"pattern": "^1\\.[0-9]+\\.[0-9]+$"
},
"flow": {
"type": "string",
"description": "Verification flow identifier (v1 aligns with OperationRun.type)."
},
"previous_report_id": {
"description": "Previous report id for the same identity (nullable).",
"type": ["string", "integer", "null"]
},
"generated_at": {
"type": "string",
"format": "date-time"
},
"identity": {
"type": "object",
"description": "Scope identifiers for what is being verified.",
"additionalProperties": true
},
"fingerprint": {
"description": "Deterministic SHA-256 hash (lowercase hex) of normalized check outcomes.",
"type": "string",
"pattern": "^[a-f0-9]{64}$"
},
"summary": {
"type": "object",
"additionalProperties": false,
"required": ["overall", "counts"],
"properties": {
"overall": {
"type": "string",
"enum": ["ready", "needs_attention", "blocked", "running"],
"description": "Overall state derived from check results."
},
"counts": {
"type": "object",
"additionalProperties": false,
"required": ["total", "pass", "fail", "warn", "skip", "running"],
"properties": {
"total": { "type": "integer", "minimum": 0 },
"pass": { "type": "integer", "minimum": 0 },
"fail": { "type": "integer", "minimum": 0 },
"warn": { "type": "integer", "minimum": 0 },
"skip": { "type": "integer", "minimum": 0 },
"running": { "type": "integer", "minimum": 0 }
}
}
}
},
"checks": {
"type": "array",
"minItems": 0,
"items": {
"$ref": "#/$defs/CheckResult"
}
}
},
"$defs": {
"CheckResult": {
"type": "object",
"additionalProperties": false,
"required": [
"key",
"title",
"status",
"severity",
"blocking",
"reason_code",
"message",
"evidence",
"next_steps"
],
"properties": {
"key": { "type": "string" },
"title": { "type": "string" },
"status": {
"type": "string",
"enum": ["pass", "fail", "warn", "skip", "running"]
},
"severity": {
"description": "Must be included for fingerprint determinism; may be empty string.",
"type": "string",
"enum": ["", "info", "low", "medium", "high", "critical"]
},
"blocking": { "type": "boolean" },
"reason_code": { "type": "string" },
"message": { "type": "string" },
"evidence": {
"type": "array",
"items": { "$ref": "#/$defs/EvidencePointer" }
},
"next_steps": {
"type": "array",
"description": "Navigation-only CTAs (links) in v1.",
"items": { "$ref": "#/$defs/NextStep" }
}
}
},
"EvidencePointer": {
"type": "object",
"additionalProperties": false,
"required": ["kind", "value"],
"properties": {
"kind": { "type": "string" },
"value": {
"description": "Safe pointer value (ID/masked string/hash).",
"oneOf": [
{ "type": "integer" },
{ "type": "string" }
]
}
}
},
"NextStep": {
"type": "object",
"additionalProperties": false,
"required": ["label", "url"],
"properties": {
"label": { "type": "string" },
"url": { "type": "string" }
}
}
}
}

View File

@ -0,0 +1,114 @@
# Data Model: Verification Checklist Framework V1.5 (075)
**Date**: 2026-02-05
**Phase**: Phase 1 (Design)
**Status**: Draft (design-complete for implementation planning)
---
## Existing Entity (reference)
### OperationRun (existing)
**Purpose**: Canonical operational record. Verification reports are stored in `operation_runs.context.verification_report` (JSONB).
**Key fields (relevant)**:
- `id`
- `tenant_id`
- `workspace_id`
- `type` (verification flow identifier)
- `run_identity_hash` (identity hash used for active dedupe + identity matching)
- `status`
- `context` (JSONB)
**Verification report storage**:
- `context.verification_report` (JSON object)
---
## New Persistent Entity
### VerificationCheckAcknowledgement (new table)
**Table name**: `verification_check_acknowledgements`
**Purpose**: First-class governance record that a failing/warning check is acknowledged for a specific report (report == operation run).
**Fields**:
- `id` (primary key)
- `tenant_id` (FK or scalar; used for tenant-scoped filtering and isolation checks)
- `workspace_id` (FK or scalar)
- `operation_run_id` (FK to `operation_runs.id`) — the “report”
- `check_key` (string)
- `ack_reason` (string, max 160)
- `expires_at` (timestamp, nullable) — informational only in v1.5
- `acknowledged_at` (timestamp)
- `acknowledged_by_user_id` (FK to `users.id`)
- `created_at`, `updated_at`
**Uniqueness constraint (required)**:
- unique `(operation_run_id, check_key)`
**Indexes (recommended)**:
- `(tenant_id, workspace_id, operation_run_id)`
- `(operation_run_id)`
**Validation rules**:
- `ack_reason`: required, string, length ≤ 160
- `expires_at`: optional, must be a valid timestamp, should be >= acknowledged_at (implementation may enforce)
**State transitions**:
- Immutable per report/check in v1.5: create once; no update/delete/unack flows.
---
## Contracted Document (stored in JSON)
### VerificationReport (JSON in `OperationRun.context.verification_report`)
**Purpose**: Structured, versioned report of verification results used by the DB-only viewer.
**Identity**:
- `report_id`: `operation_runs.id`
- `previous_report_id`: previous run id for same identity (nullable)
**New v1.5 fields**:
- `fingerprint` (string; lowercase hex; SHA-256)
- `previous_report_id` (nullable integer/uuid depending on `OperationRun` PK type)
**Existing core fields (from 074, reference)**:
- `schema_version` (SemVer string; major `1`)
- `flow` (verification flow identifier; aligns with `OperationRun.type`)
- `generated_at` (timestamp)
- `summary` (counts + overall outcome)
- `checks[]` (flat array) including:
- `key`
- `title`
- `status` (`pass|fail|warn|skip|running`)
- `severity` (`info|low|medium|high|critical` or empty string)
- `blocking` (boolean)
- `reason_code` (string)
- safe evidence pointers
- `next_steps[]` (navigation-only links)
**Fingerprint normalization input** (strict):
- Flatten all checks across `checks[]`.
- Sort by `check.key`.
- Contribute the stable tuple string:
- `key|status|blocking|reason_code|severity`
- `severity` must always be present (missing normalized to empty string).
---
## Derived/Computed View Data (not persisted)
### Change indicator
Computed in the viewer by comparing:
- current `verification_report.fingerprint`
- previous `verification_report.fingerprint`
States:
- no previous report → no indicator
- fingerprints match → “No changes since previous verification”
- fingerprints differ → “Changed since previous verification”

View File

@ -0,0 +1,150 @@
# Implementation Plan: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
**Branch**: `075-verification-v1_5` | **Date**: 2026-02-05 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/075-verification-v1-5/spec.md`
**Note**: This file is generated from the plan template and then filled in by `/speckit.plan` workflow steps.
## Summary
- Extend the existing 074 verification report system with deterministic **fingerprints** and a **previous report** link so the viewer can show “Changed / No changes”.
- Introduce per-check **acknowledgements** as first-class records (unique per report + check) with explicit confirmation and audit logging, without changing outcomes (“no greenwashing”).
- Update the Verify step UX to be operator-ready: issues-first tabs, centralized badge semantics (BADGE-001), and exactly one primary CTA depending on state.
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 (Filament v5 requires Livewire v4.0+)
**Storage**: PostgreSQL (Sail) with JSONB (`operation_runs.context`) + a new acknowledgement table
**Testing**: Pest (PHPUnit)
**Target Platform**: Web application (Sail/Docker locally; container deploy via Dokploy)
**Project Type**: web
**Performance Goals**: DB-only viewer renders quickly from stored JSON; fingerprint computation is linear in number of checks (typical report ≤ 50 checks)
**Constraints**:
- Viewer + Verify step are DB-only at render time (no outbound HTTP / Graph / job dispatch).
- All mutations require server-side authorization (RBAC-UX) and explicit confirmation.
- Status-like UI must use centralized badge semantics (BADGE-001).
**Scale/Scope**: Multiple tenants/workspaces; many runs over time; verification used in onboarding and provider workflows
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first, snapshots-second: PASS (report/ack UX; no inventory semantics changed).
- Read/write separation: PASS (viewer remains read-only; acknowledgements are explicit mutations with confirmation + audit + tests).
- Graph contract path: PASS (viewer is DB-only; no new Graph calls added by this feature).
- Deterministic capabilities: PASS (capabilities remain centrally registered; no raw strings).
- RBAC-UX: PASS (non-member access is 404; member missing capability is 403; server-side enforcement required).
- Run observability: PASS (verification remains an `OperationRun`; dedupe while active is unchanged).
- Data minimization: PASS (no secrets/tokens; audit payload excludes `ack_reason`).
- Badge semantics (BADGE-001): PASS (no new status values; existing verification badge domains remain canonical).
## Project Structure
### Documentation (this feature)
```text
specs/075-verification-v1-5/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
app/
├── Filament/
├── Jobs/
├── Models/
├── Policies/
├── Services/
└── Support/
database/
└── migrations/
resources/
routes/
tests/
```
**Structure Decision**: Single Laravel web app with Filament v5 panel. This feature extends verification report writer/viewer, adds an acknowledgement persistence model + migration, and refactors the Verify step UI in Filament.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
## Phase 0 — Research (output: `research.md`)
See: [research.md](./research.md)
Goals covered:
- Confirm canonical storage approach for report metadata (keep report in `operation_runs.context`).
- Define deterministic fingerprint algorithm and previous report resolution rules.
- Define acknowledgement persistence strategy and capability naming reconciliation.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- [data-model.md](./data-model.md)
- [contracts/](./contracts/)
- [quickstart.md](./quickstart.md)
Design focus:
- Report metadata: add `fingerprint` and `previous_report_id` inside the report JSON.
- Previous report resolution: match identity exactly (type/flow + tenant + workspace + provider connection), with `NULL` connection matching only `NULL`.
- Acknowledgements: first-class DB table keyed by `(operation_run_id, check_key)`; immutable in v1.5.
- Filament UX: issues-first tabs and “one primary CTA” rule; acknowledgements via `Action::make(...)->action(...)` + `->requiresConfirmation()`.
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
### Data
- Migration: create `verification_check_acknowledgements` table with unique `(operation_run_id, check_key)`.
- Model: `VerificationCheckAcknowledgement` with tenant/workspace scoping.
### Report writer / viewer
- Extend the report writer to compute and store `fingerprint` and `previous_report_id` (report_id is the run id).
- Extend the DB-only viewer to load previous report (when present) and compute the “changed/no-change” indicator.
- Ensure the viewer consumes acknowledgements (DB lookup) and groups “Acknowledged issues” separately.
### Authorization + audit
- Capability: add `tenant_verification.acknowledge` to the canonical capability registry and map to roles.
- Server-side auth: non-members 404; members without capability 403 for acknowledgement.
- Audit: add a new stable action ID (e.g. `verification.check_acknowledged`) with minimal metadata and no `ack_reason`.
### Filament UX
- Verify step: implement the issues-first layout and strict “exactly one primary CTA” rule.
- Actions: acknowledgement requires confirmation; navigation-only links remain links-only.
- BADGE-001: continue to use centralized badge domains for statuses and summary.
### Tests (Pest)
- Fingerprint determinism: same normalized inputs → same hash; severity-only differences → different hash.
- Previous report linking: identity match includes provider connection (`NULL` only matches `NULL`).
- RBAC-UX: non-member gets 404; member without capability gets 403 on acknowledgement.
- Audit: acknowledgement emits correct action id + minimal metadata (assert `ack_reason` absent).
- Viewer DB-only: no outbound HTTP during render/hydration.
## Constitution Check (Post-Design)
Re-check result: PASS. Design keeps report viewing DB-only, introduces a single tenant-scoped mutation with confirmation + audit, preserves RBAC-UX semantics, and maintains BADGE-001 centralized badge rendering.

View File

@ -0,0 +1,47 @@
# Quickstart: Verification Checklist Framework V1.5 (075)
This quickstart is for developers implementing and validating Spec 075.
## Prerequisites
- Docker + Sail
- A seeded workspace + tenant and a user that can access the tenant plane (`/admin/t/{tenant}`)
## Local setup
- Start containers: `vendor/bin/sail up -d`
- Install deps (if needed): `vendor/bin/sail composer install`
- Run migrations: `vendor/bin/sail artisan migrate`
## Run verification (expected UX)
After implementation, the Verify surface should behave like:
1. Navigate to the tenant-scoped Verify step (onboarding or equivalent).
2. If no active run exists, the single primary CTA is **Start verification**.
3. If a run is active, the single primary CTA is **Refresh results**.
4. Results default to the **Issues** tab with blockers/failures/warnings ordered first.
## Acknowledge a check (expected UX)
After implementation:
1. On a `fail` or `warn` check card, click **Acknowledge**.
2. Confirmation modal appears (required).
3. Submit a short reason (≤ 160 chars) and optional expiry (informational only).
4. The acknowledgement displays (who/when/reason) and the issue moves into the “Acknowledged issues” group.
## Authorization expectations
- Non-members attempting to access tenant-scoped verification pages: deny-as-not-found (404).
- Tenant members without `tenant_verification.acknowledge`: acknowledgement attempts fail with 403.
## Run the focused test suite
Once tests are implemented:
- Run only the spec-related tests: `vendor/bin/sail artisan test --compact --filter=Verification` (or point at the specific test file(s)).
## Formatting
- Format only changed files before finalizing: `vendor/bin/sail bin pint --dirty`

View File

@ -0,0 +1,119 @@
# Research: Verification Checklist Framework V1.5 (075)
**Date**: 2026-02-05
**Phase**: Phase 0 (Foundational Research)
**Status**: Complete
---
## Decisions
### D-075-001 — Canonical storage for report + metadata
**Decision**: Store the verification report (including `fingerprint` and `previous_report_id`) inside `operation_runs.context.verification_report` (JSONB), consistent with 074.
**Rationale**:
- Viewer surfaces must be DB-only at render time (constitution: Operations / Run Observability Standard).
- `OperationRun` is already the canonical operational record and stable viewer entry point.
- Adds supportability metadata without introducing a new top-level report table.
**Alternatives considered**:
- Dedicated `verification_reports` table: rejected for v1.5 to avoid new query/index surfaces; revisit if we need global querying across reports.
---
### D-075-002 — Report identity + “previous report” resolution
**Decision**: Resolve `previous_report_id` by querying the most recent earlier `OperationRun` whose **run identity** matches exactly (flow/type, tenant, workspace, provider connection where applicable).
**Rationale**:
- The existing `OperationRunService::ensureRunWithIdentity()` + `run_identity_hash` already defines the dedupe boundary.
- Matches the specs clarified rule: `provider_connection_id` must match exactly; `NULL` only matches `NULL`.
**Alternatives considered**:
- Match previous runs by only `tenant_id + workspace_id + type` and then filter in PHP: rejected due to ambiguity and risk of cross-connection mixing.
---
### D-075-003 — Report ID semantics
**Decision**: Treat the `OperationRun` ID as the report identifier in UX and contracts (`report_id == operation_run_id`).
**Rationale**:
- The report is attached to the run; the run is the stable, tenant-scoped canonical record.
- Avoids a second identifier for the same “verification execution artifact”.
**Alternatives considered**:
- Generate a separate report UUID inside the JSON: rejected as it adds indirection without benefits in v1.5.
---
### D-075-004 — Fingerprint algorithm
**Decision**: Use SHA-256 over a deterministic normalization of check outcomes:
- flatten checks
- sort by `check.key`
- contribute `key|status|blocking|reason_code|severity` where `severity` is always present (missing → empty)
Store as lowercase hex.
**Rationale**:
- Deterministic across environments.
- Treats severity-only changes as meaningful (per clarified requirement).
**Alternatives considered**:
- Hash the full report JSON: rejected (unstable ordering, non-semantic fields like timestamps).
---
### D-075-005 — Per-check acknowledgements persistence
**Decision**: Create a first-class table `verification_check_acknowledgements` keyed by `(operation_run_id, check_key)` with a unique constraint.
**Rationale**:
- Acknowledgements are governance metadata and must be queryable and auditable.
- Unique per report/check is enforced by the DB.
**Alternatives considered**:
- Store acknowledgements inside `operation_runs.context`: rejected as it complicates update semantics and auditability, and risks “report mutation” appearing like a changed verification outcome.
---
### D-075-006 — Capability naming reconciliation
**Decision**: Introduce a dedicated canonical capability `tenant_verification.acknowledge` in the capability registry and map it in the role → capability map.
**Rationale**:
- Keeps the feature spec requirement literal and avoids overloading “findings” semantics.
- Preserves the constitution rule that capabilities are centrally registered (no raw strings).
**Alternatives considered**:
- Reuse existing `tenant_findings.acknowledge`: rejected because this feature is specifically verification-report scoped, and we want the permission surface to remain explicit.
---
### D-075-007 — Audit action identifier + payload minimization
**Decision**: Add a stable audit action ID for acknowledgements (e.g. `verification.check_acknowledged`) and emit it on successful acknowledgement. Audit metadata is minimal and MUST NOT include `ack_reason`.
**Rationale**:
- Acknowledgement is a write mutation; constitution requires audit logging.
- Spec explicitly excludes `ack_reason` from audit payload; it remains only in the acknowledgement record.
**Alternatives considered**:
- Reuse `verification.completed`: rejected because it conflates verification execution with governance mutation.
---
### D-075-008 — Filament UI implementation constraints
**Decision**: Implement the “Verify step” UX changes in Filament v5 (Livewire v4) using:
- DB-only viewer helper (no external calls)
- centralized badge domains (BADGE-001)
- mutation via Filament `Action::make(...)->action(...)` with `->requiresConfirmation()`
**Rationale**:
- Aligns with Filament v5 patterns and constitution rules.
**Alternatives considered**:
- Publish/override Filament internal views: rejected; prefer render hooks + CSS hooks as needed.

View File

@ -0,0 +1,225 @@
# Feature Specification: Verification Checklist Framework V1.5 (Governance + Supportability + UI-Complete)
**Feature Branch**: `075-verification-v1_5`
**Created**: 2026-02-05
**Status**: Draft
**Input**: User description: "Extend verification checklist framework with report fingerprint + previous report change indicator, per-check acknowledgements with audit/confirmation, and issues-first operator-ready verify-step UX."
## Goal
V1.5 extends the V1 verification checklist framework with two enterprise-critical additions while keeping scope intentionally small:
1) **Supportability / determinism**: show whether results changed since the previous verification for the same identity.
2) **Governance**: allow explicit, auditable acknowledgement of known issues per failing check.
3) **Enterprise UX completeness**: make the Verify step operator-ready (issues-first, one clear primary action, technical details secondary).
## Clarifications
### Session 2026-02-05
- Q: How is “block” represented on checks? → A: No new status; a “Blocker” is `status=fail` with `blocking=true`.
- Q: Should `severity` be part of the fingerprint? → A: Yes; include `severity` always (normalize missing to empty) to keep hashing deterministic and to treat severity-only changes as “Changed”.
- Q: Should `ack_reason` be included in the audit event payload? → A: No; keep audit metadata minimal and store the reason only in the acknowledgement record.
- Q: How should `provider_connection_id` be treated when resolving `previous_report_id`? → A: Match exactly; `NULL` only matches `NULL` (no cross-connection mixing).
- **Report shape (canonical, inherited from 074)**: Persist reports as the existing V1 JSON shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`). V1.5 adds `fingerprint` + `previous_report_id` at the top level. No `sections[]` array is stored.
- **Idempotency (inherited)**: Deduplication applies only while a run is active (`queued` / `running`). Once `completed` / `failed`, starting verification creates a new run.
- **Viewing (inherited)**: Viewing is DB-only; rendering MUST NOT perform external calls.
- **Evidence (inherited)**: Evidence is limited to safe pointers only; no secrets (no tokens/claims/headers/raw payloads).
- **Next steps (inherited)**: Navigation-only links (no server-side “fix it” actions from the viewer).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Operator can tell “nothing changed” (Priority: P1)
As an operator, I can immediately see whether the current verification findings are unchanged compared to the previous verification for the same identity, so I can avoid unnecessary re-diagnosis.
**Why this priority**: This is the fastest path to supportability: it reduces repeated analysis and makes troubleshooting deterministic.
**Independent Test**: Create two reports for the same identity with identical normalized check outcomes; confirm the viewer indicates “No changes since previous verification”.
**Acceptance Scenarios**:
1. **Given** a report with a previous report available, **When** I open the viewer, **Then** I see a clear indicator “Changed” or “No changes”.
2. **Given** the current report has the same fingerprint as the previous report, **When** I open the viewer, **Then** I see “No changes since previous verification”.
---
### User Story 2 - Owner/Manager can acknowledge a known issue (Priority: P1)
As an owner/manager, I can acknowledge a failing/warning/blocking check with a short reason (and optionally an expiry) so the team can see that the risk is known, evaluated, and accepted.
**Why this priority**: Acknowledgements provide governance without masking risk; they improve shared context and auditability.
**Independent Test**: With and without the acknowledgement capability, attempt to acknowledge a failing check; assert correct authorization (403) and that an audit event is recorded for the successful path.
**Acceptance Scenarios**:
1. **Given** a check in status `fail` / `warn` (including failing blockers where `blocking=true`), **When** I acknowledge it with a reason, **Then** the UI shows who acknowledged it, when, and the reason.
2. **Given** I do not have the acknowledgement capability, **When** I attempt to acknowledge a check, **Then** the server returns 403 and the UI does not offer the acknowledgement action.
---
### User Story 3 - Verify step is operator-ready (issues-first) (Priority: P1)
As a workspace member, I see issues-first results with clear next steps and exactly one primary action (start or refresh), so I can remediate quickly without hunting through technical details.
**Why this priority**: The Verify step is a high-frequency operator surface; clarity and deterministic states reduce time-to-resolution.
**Independent Test**: Seed a report with blockers and a running state; confirm the default tab and “one primary CTA” rule is enforced in both completed and running scenarios.
**Acceptance Scenarios**:
1. **Given** a report with blockers, **When** I open the Verify step/viewer, **Then** the Issues tab is the default and blockers are at the top.
2. **Given** a run is active, **When** I open the Verify step/viewer, **Then** the primary action is “Refresh results” and technical links are secondary.
---
### Edge Cases
- No previous report exists for an identity → no “changed/no-change” indicator is shown.
- Run is active but no report is available yet → UI shows a clear “running, results will appear” explanation (no empty states without guidance).
- Partial report while running → partial results render with a “Partial results” label.
- Unknown check keys or reason codes → UI degrades gracefully, showing status and message without breaking.
- Acknowledgement attempted for non-acknowledgeable status (e.g., `pass`) → request is rejected and UI does not offer it.
## Out of Scope
- Diff/compare UI between reports
- Server-side fixes initiated from the viewer
- Undo / unacknowledge acknowledgements (V1.5 acknowledgements are immutable per report)
- Complex staleness/TTL semantics (fresh/stale/expired)
- Global dashboards / cross-tenant reporting
- Export features (PDF/JSON) as a product feature
- Live polling (V1.5 uses manual refresh)
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds new tenant-scoped mutations (acknowledgements) and new report metadata. It MUST include explicit confirmation, audit logging for mutations, tenant isolation, and tests.
**Constitution alignment (RBAC-UX):** Tenant-scoped routes MUST preserve deny-as-not-found (404) for non-members, and use 403 for members missing a capability. UI visibility is not authorization; server-side enforcement is required.
**Constitution alignment (BADGE-001):** Status-like badges MUST use centralized mapping semantics; no ad-hoc UI mappings.
### Functional Requirements
- **FR-075-001 — Report fingerprint**: Each verification report MUST store a deterministic `fingerprint` derived from normalized check outcomes.
**Normalization rule (deterministic):**
- Flatten all check results across `report.checks[]`
- Sort by stable `check.key`
- For each check, contribute a stable string using: `key | status | blocking | reason_code | severity`
- `severity` MUST be included always; if the source report omits it, normalize to an empty string
- The fingerprint MUST be a stable cryptographic hash of the joined contributions, stored as a fixed-length lowercase hex string.
- **FR-075-002 — Previous report link**: Each report MUST store `previous_report_id` (nullable) that points to the most recent earlier report for the same **verification identity**.
**Identity match** MUST include:
- flow
- workspace
- tenant
- provider connection (`provider_connection_id`) matched exactly; `NULL` only matches `NULL`
- **FR-075-003 — Change indicator**: When a previous report exists, the viewer MUST show:
- “No changes since previous verification” if `fingerprint` matches
- “Changed since previous verification” otherwise
- **FR-075-004 — Per-check acknowledgements (first-class)**: The system MUST allow acknowledging checks with status `fail` / `warn`.
An acknowledgement MUST record:
- reason (max 160 characters)
- acknowledged timestamp
- acknowledged-by user
- optional expiry timestamp
Acknowledgements MUST be unique per (report, check key). Expiry, when provided, is informational only in V1.5 and MUST NOT introduce automatic staleness/TTL behavior.
- **FR-075-005 — Acknowledgement does not change outcomes**: Acknowledging MUST NOT change:
- the check status
- the report summary status/outcome
- the run outcome
- **FR-075-006 — Acknowledgement allowed conditions**: Acknowledgement MUST only be possible for checks whose status is in `{fail, warn}`. It MUST NOT be available for passing/green checks.
A check is considered a **Blocker** when `status=fail` and `blocking=true`; blockers are acknowledgeable under the same `{fail, warn}` rule (no separate `block` status exists).
- **FR-075-007 — Acknowledgement authorization (capability-first)**: Acknowledgement MUST require the capability `tenant_verification.acknowledge` as defined in the canonical capability registry.
RBAC UX semantics:
- non-member / not entitled to tenant scope → 404
- member without acknowledgement capability → 403
- members with tenant scope but without acknowledgement capability can still view reports (view remains read-only)
- **FR-075-007A — Viewing authorization semantics preserved (inherited)**: Viewing tenant-scoped verification pages (Verify step + report viewer) MUST preserve V1 semantics:
- non-member / not entitled to tenant scope → 404
- member with tenant scope → can view
- capability checks apply to mutations only (start verification, acknowledgement)
- **FR-075-008 — Confirmation + audit required**: Acknowledgement is a mutation and MUST require explicit user confirmation and MUST emit an audit event.
- **FR-075-009 — Audit event metadata (minimal)**: The audit event for acknowledgement MUST include minimally:
- workspace, tenant, run, report, flow
- check key and reason code
- acknowledged-by user
It MUST NOT include `ack_reason`, secrets, tokens, or raw payloads.
- **FR-075-010 — DB-only viewing guard (inherited)**: Rendering the viewer and the Verify step MUST NOT trigger external calls.
- **FR-075-011 — Centralized badge semantics (BADGE-001)**: All check-status badges and summary-status badges used by V1.5 MUST use the centralized badge mapping registry.
- **FR-075-012 — Verify step enterprise UX (normative)**: The Verify step/viewer MUST follow an issues-first layout and deterministic UI states:
**Structure**
- Always-visible summary card
- Tabs: Issues (default), Passed, Technical details
**DB-only hint**
- The summary surface MUST include a clear hint that viewing is read-only and performs no external calls.
**Primary action rule (strict)**
- Exactly one primary call-to-action is shown at any time
- “Start verification” and “Refresh results” MUST NOT both be primary simultaneously
**Issues tab ordering**
1) Blockers (not acknowledged)
2) Failures (not acknowledged)
3) Warnings (not acknowledged)
4) Acknowledged issues (collapsed group)
**Next steps rendering**
- Max 2 navigation-only links per issue card
- “Open run details” MUST appear only in Technical details (not in issue cards)
**Technical details**
- Secondary surface that can show identifiers (run/report IDs), fingerprint, and previous report link
- No raw payloads/tokens/full error bodies
### Key Entities *(include if feature involves data)*
- **Verification Identity**: The stable identifiers that define “what is being verified” (flow, workspace, tenant, and optional provider connection).
- **Verification Report**: A structured record of verification outcomes for a run.
- **Report Fingerprint**: A deterministic hash representing normalized check outcomes.
- **Previous Report**: The immediately preceding report for the same identity.
- **Check Acknowledgement**: A governance record that an issue is known/accepted (who/when/reason/optional expiry) without altering the check outcome.
### Assumptions
- A verification run/report concept already exists from V1.
- The system has an audit log mechanism capable of recording acknowledgement actions.
- Manual refresh is acceptable (no polling required).
### Dependencies
- Spec 074 (Verification Checklist Framework V1)
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-075-001 (Supportability)**: With a previous report present, operators can determine “changed vs no changes” within 10 seconds in 95% of tested sessions.
- **SC-075-002 (Governance)**: 100% of successful acknowledgements create an audit log record with minimal metadata and no sensitive content.
- **SC-075-003 (UX determinism)**: The Verify step renders exactly one primary CTA in all tested UI states (not started, running with/without report, completed).
- **SC-075-004 (Authorization correctness)**: Non-members receive 404 for tenant-scoped access routes in 100% of tests; members without acknowledgement capability receive 403 for acknowledgement attempts in 100% of tests.
- **SC-075-005 (No greenwashing)**: Acknowledging an issue never changes check status or the report summary in any tested scenario.
```

View File

@ -0,0 +1,172 @@
---
description: "Task breakdown for Spec 075 (Verification Checklist Framework V1.5)"
---
# Tasks: Verification Checklist Framework V1.5 (075)
**Input**: Design documents from `/specs/075-verification-v1-5/`
**Tests**: REQUIRED (Pest)
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Align feature artifacts with the existing 074 verification implementation (report shape, DB-only viewing constraints).
- [X] T001 Reconcile v1.5 report contract to match the V1 report shape (`schema_version`, `flow`, `generated_at`, `summary`, `checks[]`) + v1.5 fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in specs/075-verification-v1-5/contracts/verification-report.v1_5.schema.json
- [X] T002 [P] Confirm viewer surfaces are DB-only using existing guard helpers in tests/Support/AssertsNoOutboundHttp.php (helper availability + correct usage patterns)
- [X] T003 [P] Identify all report viewer templates to update: resources/views/filament/components/verification-report-viewer.blade.php and resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Shared primitives required by all user stories (schema/sanitization, stable fingerprint, previous report resolution).
**⚠️ CRITICAL**: Complete this phase before implementing US1/US2/US3.
- [X] T004 Update report schema to allow v1.5 metadata fields (`fingerprint`, `previous_report_id`) and allow empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSchema.php
- [X] T005 Update report sanitizer to preserve v1.5 metadata fields (`fingerprint`, `previous_report_id`) and preserve empty-string `severity` (missing → empty) in app/Support/Verification/VerificationReportSanitizer.php
- [X] T006 [P] Add a deterministic fingerprint helper in app/Support/Verification/VerificationReportFingerprint.php (flatten `checks[]`; normalize missing `severity` to empty string, not `info`)
- [X] T007 Add a previous-report resolver helper in app/Support/Verification/PreviousVerificationReportResolver.php
- [X] T008 [P] Add or update verification badge mapping tests in tests/Feature/Badges/ to cover all v1.5-used status-like values (BADGE-001)
**Checkpoint**: Schema + sanitizer accept v1.5 fields; fingerprint + previous-report resolver are available for use.
---
## Phase 3: User Story 1 — Operator can tell “nothing changed” (Priority: P1) 🎯 MVP
**Goal**: Persist a deterministic `fingerprint` + `previous_report_id` on each report, and show “Changed / No changes” when a previous report exists.
**Independent Test**: Create two completed verification runs for the same identity with identical normalized outcomes; confirm viewer indicates “No changes since previous verification”.
### Tests for User Story 1 (write first)
- [X] T009 [P] [US1] Add fingerprint determinism unit tests in tests/Feature/Verification/VerificationReportFingerprintTest.php (including missing severity → empty string, and severity-only changes → different hash)
- [X] T010 [P] [US1] Add previous report identity matching tests (provider_connection_id exact match; NULL matches NULL) and a regression proving cross-connection runs dont match when run_identity_hash includes provider_connection_id in tests/Feature/Verification/PreviousVerificationReportResolverTest.php
### Implementation for User Story 1
- [X] T011 [US1] Compute and persist report fingerprint in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/VerificationReportFingerprint.php)
- [X] T012 [US1] Resolve and persist previous_report_id during write in app/Support/Verification/VerificationReportWriter.php (use app/Support/Verification/PreviousVerificationReportResolver.php + run_identity_hash; verify all verification run start paths include provider_connection_id in identityInputs)
- [X] T013 [P] [US1] Extend DB-only report viewer helper to expose v1.5 metadata in app/Filament/Support/VerificationReportViewer.php
- [X] T014 [US1] Add change-indicator computation for viewer surfaces in app/Filament/Support/VerificationReportChangeIndicator.php
**Checkpoint**: Report JSON includes `fingerprint` + `previous_report_id`; viewer can derive Changed/No changes.
---
## Phase 4: User Story 2 — Owner/Manager can acknowledge a known issue (Priority: P1)
**Goal**: Acknowledge `fail` / `warn` checks per report with confirmation + audit, without changing check outcomes.
**Independent Test**: Attempt to acknowledge a failing check (a) as non-member → 404, (b) as member without capability → 403, (c) with capability → record created + audit logged.
### Tests for User Story 2 (write first)
- [X] T015 [P] [US2] Add acknowledgement authorization + audit tests in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php (404 non-member, 403 missing capability, persists optional expires_at; audit metadata includes check_key + reason_code and excludes ack_reason)
### Implementation for User Story 2
- [X] T016 [US2] Create migration for verification_check_acknowledgements table (includes optional expires_at; informational only) in database/migrations/*_create_verification_check_acknowledgements_table.php
- [X] T017 [P] [US2] Create model in app/Models/VerificationCheckAcknowledgement.php
- [X] T018 [P] [US2] Create factory for acknowledgements in database/factories/VerificationCheckAcknowledgementFactory.php
- [X] T019 [US2] Implement acknowledgement creation service in app/Services/Verification/VerificationCheckAcknowledgementService.php (server-side authorization via Gate/policy; validate status ∈ {fail,warn}; validate optional expires_at; enforce unique per (operation_run_id, check_key))
- [X] T020 [P] [US2] Register capability constant tenant_verification.acknowledge in app/Support/Auth/Capabilities.php
- [X] T021 [P] [US2] Map tenant_verification.acknowledge to tenant roles in app/Services/Auth/RoleCapabilityMap.php
- [X] T022 [P] [US2] Add audit action id for acknowledgement in app/Support/Audit/AuditActionId.php (e.g. verification.check_acknowledged)
- [X] T023 [US2] Emit audit event with minimal metadata via app/Services/Audit/WorkspaceAuditLogger.php from the acknowledgement path (MUST include: tenant_id, operation_run_id/report_id, flow, check_key, reason_code; MUST NOT include ack_reason)
**Checkpoint**: Acknowledgements are persisted, authorized, confirmed in UI (next story), and audited with minimized metadata.
---
## Phase 5: User Story 3 — Verify step is operator-ready (issues-first) (Priority: P1)
**Goal**: Issues-first view, centralized badge semantics (BADGE-001), DB-only hint, and exactly one primary CTA depending on state.
**Independent Test**: Seed a run with blockers while completed and while running; confirm Issues is default, ordering rules hold, and one-primary-CTA rule holds.
### Tests for User Story 3 (write first)
- [X] T024 [P] [US3] Add Verify-step CTA and ordering tests in tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php
- [X] T025 [P] [US3] Add DB-only render guard test coverage for Verify surfaces in tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php
### Implementation for User Story 3
- [X] T026 [US3] Enforce “exactly one primary CTA” logic in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (start vs refresh)
- [X] T027 [US3] Refactor Verify-step report view to issues-first tabs + ordering + DB-only hint in resources/views/filament/forms/components/managed-tenant-onboarding-verification-report.blade.php
- [X] T028 [US3] Add per-check acknowledgement action UI with confirmation in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (Action::make(...)->action(...)->requiresConfirmation())
- [X] T029 [US3] Wire acknowledgement UI to service + RBAC semantics (404 non-member, 403 missing capability; server-side enforcement required) in app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
- [X] T030 [US3] Update the Monitoring viewer to match v1.5 UX rules (issues-first tabs: Issues default, Passed, Technical details; ordering; next-steps max 2) in resources/views/filament/components/verification-report-viewer.blade.php
- [X] T031 [P] [US3] Show change indicator + previous report link in technical details (no raw payloads) in resources/views/filament/components/verification-report-viewer.blade.php
**Checkpoint**: Verify UX is deterministic, issues-first, and operator-ready across onboarding and monitoring surfaces.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Hardening, formatting, and regression coverage.
- [X] T032 [P] Ensure acknowledgement does not mutate check status/summary in app/Support/Verification/VerificationReportWriter.php and cover with assertions in tests/Feature/Verification/VerificationCheckAcknowledgementTest.php
- [X] T033 [P] Add redaction regression checks for new v1.5 fields (fingerprint/previous_report_id) in tests/Feature/Verification/VerificationReportRedactionTest.php
- [X] T034 [P] Run Pint on changed files via vendor/bin/sail bin pint --dirty
- [X] T035 Run focused test suite via vendor/bin/sail artisan test --compact --filter=Verification
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: start immediately
- **Foundational (Phase 2)**: blocks all user stories
- **User Stories (Phase 35)**:
- US1 depends on Phase 2
- US2 depends on Phase 2
- US3 depends on Phase 2 and benefits from US1 + US2 completion
- **Polish (Phase 6)**: after US1US3
### User Story Dependencies (Graph)
- **US1 (Fingerprint + previous report + changed indicator)** → enables technical details and “Changed/No changes” banner in US3
- **US2 (Acknowledgements)** → enables “Acknowledged issues” grouping and action UX in US3
- **US3 (Verify UX)** → integrates outputs of US1 + US2 into operator surface
---
## Parallel Execution Examples
### US1
- Run in parallel:
- T009 (fingerprint determinism tests) + T010 (previous resolver tests)
- T013 (viewer helper exposure) can proceed while T011/T012 land
### US2
- Run in parallel:
- T017 (model) + T018 (factory) + T020 (capability constant) + T021 (role mapping) + T022 (audit action id)
### US3
- Run in parallel:
- T024 (UX tests) + T025 (DB-only tests)
- T027 (onboarding blade refactor) + T030 (monitoring viewer refactor)
---
## Implementation Strategy
### MVP First (US1)
1. Phase 1 → Phase 2
2. Implement US1 (Phase 3)
3. Validate: run T035 and confirm “No changes since previous verification” path
### Incremental Delivery
1. US1 (supportability) → US2 (governance) → US3 (operator UX)
2. After each story, run story-specific tests plus `vendor/bin/sail artisan test --compact --filter=Verification`

View File

@ -0,0 +1,47 @@
<?php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps verification check status fail to a Fail danger badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'fail');
expect($spec->label)->toBe('Fail');
expect($spec->color)->toBe('danger');
expect($spec->icon)->toBe('heroicon-m-x-circle');
});
it('normalizes verification check status input before mapping', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckStatus, 'RUNNING');
expect($spec->label)->toBe('Running');
});
it('maps verification check severity critical to a Critical danger badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, 'critical');
expect($spec->label)->toBe('Critical');
expect($spec->color)->toBe('danger');
expect($spec->icon)->toBe('heroicon-m-x-circle');
});
it('maps empty verification check severity to an Unknown badge (v1.5 allows empty severity)', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationCheckSeverity, '');
expect($spec->label)->toBe('Unknown');
});
it('maps verification report overall needs_attention to a Needs attention warning badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'needs_attention');
expect($spec->label)->toBe('Needs attention');
expect($spec->color)->toBe('warning');
expect($spec->icon)->toBe('heroicon-m-exclamation-triangle');
});
it('normalizes verification report overall input before mapping', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::VerificationReportOverall, 'NEEDS ATTENTION');
expect($spec->label)->toBe('Needs attention');
});

View File

@ -10,6 +10,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -102,13 +103,19 @@
'entra_tenant_id' => $entraTenantId,
'entra_tenant_name' => 'Contoso',
],
],
'failure_summary' => [
[
'code' => 'provider.connection.check.failed',
'reason_code' => 'permission_denied',
'message' => 'Missing required Graph permissions.',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'permission_check',
'title' => 'Graph permissions',
'status' => 'fail',
'severity' => 'high',
'blocking' => true,
'reason_code' => 'permission_denied',
'message' => 'Missing required Graph permissions.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
@ -127,7 +134,7 @@
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('permission_denied')
->assertSee('Missing required Graph permissions.')
->assertSee('Graph permissions')
->assertSee($entraTenantId);
});

View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
it('shows exactly one verification CTA depending on run state', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Start verification')
->assertDontSee('Refresh');
$runningRun = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'running',
'outcome' => 'pending',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
],
]);
TenantOnboardingSession::query()
->where('workspace_id', (int) $workspace->getKey())
->where('tenant_id', (int) $tenant->getKey())
->update([
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $runningRun->getKey(),
],
]);
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Refresh')
->assertDontSee('Start verification');
});
it('orders issues deterministically and groups acknowledged issues', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
$checks = [
[
'key' => 'acknowledged_fail',
'title' => 'Acked failure',
'status' => 'fail',
'severity' => 'medium',
'blocking' => false,
'reason_code' => 'invalid_state',
'message' => 'Already known.',
'evidence' => [],
'next_steps' => [],
],
[
'key' => 'warning',
'title' => 'Warning check',
'status' => 'warn',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'not_applicable',
'message' => 'Something is slightly off.',
'evidence' => [],
'next_steps' => [],
],
[
'key' => 'failure',
'title' => 'Failure check',
'status' => 'fail',
'severity' => 'high',
'blocking' => false,
'reason_code' => 'missing_configuration',
'message' => 'This must be fixed.',
'evidence' => [],
'next_steps' => [],
],
[
'key' => 'blocker',
'title' => 'Blocker check',
'status' => 'fail',
'severity' => 'critical',
'blocking' => true,
'reason_code' => 'permission_denied',
'message' => 'Cannot proceed.',
'evidence' => [],
'next_steps' => [
['label' => 'First step', 'url' => '/admin/help/first'],
['label' => 'Second step', 'url' => '/admin/help/second'],
['label' => 'Third step', 'url' => '/admin/help/third'],
],
],
[
'key' => 'pass',
'title' => 'Passed check',
'status' => 'pass',
'severity' => '',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Looks good.',
'evidence' => [],
'next_steps' => [],
],
];
$report = VerificationReportWriter::build('provider.connection.check', $checks);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => $report,
],
]);
VerificationCheckAcknowledgement::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => (int) $run->getKey(),
'check_key' => 'acknowledged_fail',
'ack_reason' => 'Known issue accepted.',
'acknowledged_by_user_id' => (int) $user->getKey(),
'acknowledged_at' => now(),
]);
TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Read-only')
->assertSeeInOrder([
'Blocker check',
'Failure check',
'Warning check',
'Acknowledged issues',
'Acked failure',
])
->assertSee('Known issue accepted.')
->assertSee('First step')
->assertSee('Second step')
->assertDontSee('Third step');
});

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Services\Verification\StartVerification;
use App\Support\OperationRunStatus;
use App\Support\Verification\PreviousVerificationReportResolver;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue;
it('resolves the previous report id for the same identity (including provider_connection_id)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connectionId = (int) ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
])->getKey();
$previous = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'run_identity_hash' => 'same-hash',
'context' => [
'provider_connection_id' => $connectionId,
],
]);
$current = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'run_identity_hash' => 'same-hash',
'context' => [
'provider_connection_id' => $connectionId,
],
]);
expect(PreviousVerificationReportResolver::resolvePreviousReportId($current))
->toBe((int) $previous->getKey());
});
it('does not resolve previous report ids across provider connections', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connectionA = (int) ProviderConnection::factory()->create(['tenant_id' => (int) $tenant->getKey()])->getKey();
$connectionB = (int) ProviderConnection::factory()->create(['tenant_id' => (int) $tenant->getKey()])->getKey();
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'run_identity_hash' => 'same-hash',
'context' => [
'provider_connection_id' => $connectionA,
],
]);
$current = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'run_identity_hash' => 'same-hash',
'context' => [
'provider_connection_id' => $connectionB,
],
]);
expect(PreviousVerificationReportResolver::resolvePreviousReportId($current))
->toBeNull();
});
it('includes provider_connection_id in the verification run identity hash (no cross-connection dedupe)', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'operator');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connectionA = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$connectionB = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
]);
$starter = app(StartVerification::class);
$runA = $starter->providerConnectionCheck(
tenant: $tenant,
connection: $connectionA,
initiator: $user,
)->run->refresh();
$runB = $starter->providerConnectionCheck(
tenant: $tenant,
connection: $connectionB,
initiator: $user,
)->run->refresh();
expect($runA->getKey())->not->toBe($runB->getKey());
expect($runA->run_identity_hash)->not->toBe($runB->run_identity_hash);
});

View File

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Verification\VerificationCheckAcknowledgementService;
use App\Support\Audit\AuditActionId;
use App\Support\Verification\VerificationReportFingerprint;
use Illuminate\Auth\Access\AuthorizationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
it('returns 404 for non-members on verification check acknowledgement', function (): void {
$tenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
[$user] = createUserWithTenant($otherTenant, role: 'readonly');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => json_decode(
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
true,
512,
JSON_THROW_ON_ERROR,
),
],
]);
expect(fn () => app(VerificationCheckAcknowledgementService::class)->acknowledge(
tenant: $tenant,
run: $run,
checkKey: 'provider_connection.token_acquisition',
ackReason: 'Known issue',
expiresAt: null,
actor: $user,
))->toThrow(NotFoundHttpException::class);
});
it('returns 403 for members without tenant_verification.acknowledge on verification check acknowledgement', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => json_decode(
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
true,
512,
JSON_THROW_ON_ERROR,
),
],
]);
expect(fn () => app(VerificationCheckAcknowledgementService::class)->acknowledge(
tenant: $tenant,
run: $run,
checkKey: 'provider_connection.token_acquisition',
ackReason: 'Known issue',
expiresAt: null,
actor: $user,
))->toThrow(AuthorizationException::class);
});
it('acknowledges a failing check (with optional expiry) and writes a minimal audit log (no ack_reason)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => json_decode(
(string) file_get_contents(base_path('specs/074-verification-checklist/contracts/examples/fail.json')),
true,
512,
JSON_THROW_ON_ERROR,
),
],
]);
$reportBefore = $run->context['verification_report'] ?? null;
$reportBefore = is_array($reportBefore) ? $reportBefore : [];
$summaryBefore = is_array($reportBefore['summary'] ?? null) ? $reportBefore['summary'] : [];
$countsBefore = is_array($summaryBefore['counts'] ?? null) ? $summaryBefore['counts'] : [];
$checksBefore = is_array($reportBefore['checks'] ?? null) ? $reportBefore['checks'] : [];
$checksBeforeByKey = collect($checksBefore)
->filter(fn ($check): bool => is_array($check) && is_string($check['key'] ?? null))
->mapWithKeys(fn (array $check): array => [
(string) $check['key'] => [
'status' => $check['status'] ?? null,
'blocking' => $check['blocking'] ?? null,
'severity' => $check['severity'] ?? null,
'reason_code' => $check['reason_code'] ?? null,
],
])
->all();
$fingerprintBefore = VerificationReportFingerprint::forReport($reportBefore);
$expiresAt = now()->addDay()->toISOString();
$ack = app(VerificationCheckAcknowledgementService::class)->acknowledge(
tenant: $tenant,
run: $run,
checkKey: 'provider_connection.token_acquisition',
ackReason: 'Known issue',
expiresAt: $expiresAt,
actor: $user,
);
expect($ack->operation_run_id)->toBe((int) $run->getKey());
expect($ack->check_key)->toBe('provider_connection.token_acquisition');
expect($ack->ack_reason)->toBe('Known issue');
expect($ack->expires_at)->not->toBeNull();
$run->refresh();
$reportAfter = $run->context['verification_report'] ?? null;
$reportAfter = is_array($reportAfter) ? $reportAfter : [];
$summaryAfter = is_array($reportAfter['summary'] ?? null) ? $reportAfter['summary'] : [];
$countsAfter = is_array($summaryAfter['counts'] ?? null) ? $summaryAfter['counts'] : [];
expect($summaryAfter['overall'] ?? null)->toBe($summaryBefore['overall'] ?? null);
expect($countsAfter)->toBe($countsBefore);
$checksAfter = is_array($reportAfter['checks'] ?? null) ? $reportAfter['checks'] : [];
$checksAfterByKey = collect($checksAfter)
->filter(fn ($check): bool => is_array($check) && is_string($check['key'] ?? null))
->mapWithKeys(fn (array $check): array => [
(string) $check['key'] => [
'status' => $check['status'] ?? null,
'blocking' => $check['blocking'] ?? null,
'severity' => $check['severity'] ?? null,
'reason_code' => $check['reason_code'] ?? null,
],
])
->all();
expect($checksAfterByKey)->toBe($checksBeforeByKey);
$fingerprintAfter = VerificationReportFingerprint::forReport($reportAfter);
expect($fingerprintAfter)->toBe($fingerprintBefore);
$audit = AuditLog::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('action', AuditActionId::VerificationCheckAcknowledged->value)
->latest('id')
->first();
expect($audit)->not->toBeNull();
expect($audit?->metadata)->toMatchArray([
'tenant_id' => (int) $tenant->getKey(),
'operation_run_id' => (int) $run->getKey(),
'report_id' => (int) $run->getKey(),
'flow' => 'provider.connection.check',
'check_key' => 'provider_connection.token_acquisition',
'reason_code' => 'authentication_failed',
]);
$metadata = $audit?->metadata ?? [];
expect($metadata)->not->toHaveKey('ack_reason');
expect(json_encode($metadata))->not->toContain('Known issue');
});

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use App\Support\Verification\VerificationReportFingerprint;
it('computes the same fingerprint regardless of check ordering', function (): void {
$checksA = [
['key' => 'b', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'missing_configuration', 'severity' => 'high'],
['key' => 'a', 'status' => 'pass', 'blocking' => false, 'reason_code' => 'ok', 'severity' => 'info'],
];
$checksB = [
['key' => 'a', 'status' => 'pass', 'blocking' => false, 'reason_code' => 'ok', 'severity' => 'info'],
['key' => 'b', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'missing_configuration', 'severity' => 'high'],
];
expect(VerificationReportFingerprint::forChecks($checksA))
->toBe(VerificationReportFingerprint::forChecks($checksB));
});
it('treats missing severity as empty string for fingerprint determinism', function (): void {
$withMissingSeverity = [
['key' => 'a', 'status' => 'fail', 'blocking' => true, 'reason_code' => 'permission_denied'],
];
$withEmptySeverity = [
['key' => 'a', 'status' => 'fail', 'blocking' => true, 'reason_code' => 'permission_denied', 'severity' => ''],
];
expect(VerificationReportFingerprint::forChecks($withMissingSeverity))
->toBe(VerificationReportFingerprint::forChecks($withEmptySeverity));
});
it('treats severity-only changes as different fingerprints (missing != info)', function (): void {
$missingSeverity = [
['key' => 'a', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'unknown_error'],
];
$infoSeverity = [
['key' => 'a', 'status' => 'fail', 'blocking' => false, 'reason_code' => 'unknown_error', 'severity' => 'info'],
];
expect(VerificationReportFingerprint::forChecks($missingSeverity))
->not->toBe(VerificationReportFingerprint::forChecks($infoSeverity));
});

View File

@ -4,6 +4,7 @@
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
use App\Models\OperationRun;
use App\Support\Verification\VerificationReportFingerprint;
use Filament\Facades\Filament;
use Livewire\Livewire;
@ -21,12 +22,9 @@
JSON_THROW_ON_ERROR,
);
$report['checks'][0]['evidence'][] = ['kind' => 'authorization', 'value' => 'Bearer abc.def.ghi'];
$report['checks'][0]['evidence'][] = ['kind' => 'access_token', 'value' => 'super-secret'];
$report['checks'][0]['message'] = 'Authorization: Bearer abc.def.ghi access_token=super-secret';
$run = OperationRun::factory()->create([
$previousRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
@ -36,9 +34,30 @@
],
]);
assertNoOutboundHttp(function () use ($run): void {
$report['checks'][0]['evidence'][] = ['kind' => 'authorization', 'value' => 'Bearer abc.def.ghi'];
$report['checks'][0]['evidence'][] = ['kind' => 'access_token', 'value' => 'super-secret'];
$report['checks'][0]['message'] = 'Authorization: Bearer abc.def.ghi access_token=super-secret';
$report['previous_report_id'] = (int) $previousRun->getKey();
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'verification_report' => $report,
],
]);
$fingerprint = VerificationReportFingerprint::forReport($report);
assertNoOutboundHttp(function () use ($run, $fingerprint): void {
Livewire::test(ViewOperationRun::class, ['record' => $run->getRouteKey()])
->assertSee('Verification report')
->assertSee('Open previous verification')
->assertSee($fingerprint)
->assertSee('Token acquisition works')
->assertDontSee('access_token')
->assertDontSee('Bearer abc.def.ghi')

View File

@ -4,8 +4,17 @@
use App\Filament\Resources\OperationRunResource\Pages\ViewOperationRun;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
it('renders the verification report viewer DB-only (no outbound HTTP, no job dispatch)', function (): void {
@ -48,3 +57,79 @@
Bus::assertNothingDispatched();
});
it('renders onboarding verify surfaces DB-only (no outbound HTTP, no job/queue dispatch)', function (): void {
Bus::fake();
Queue::fake();
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'is_default' => true,
]);
$report = VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'onboarding_check',
'title' => 'Onboarding check',
'status' => 'fail',
'severity' => 'high',
'blocking' => false,
'reason_code' => 'missing_configuration',
'message' => 'Setup missing.',
'evidence' => [],
'next_steps' => [],
],
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => $report,
],
]);
TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
assertNoOutboundHttp(function () use ($user): void {
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Onboarding check');
});
Bus::assertNothingDispatched();
Queue::assertNothingPushed();
});

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Support\Audit\AuditContextSanitizer;
it('does not redact dot-separated flow identifiers', function (): void {
expect(AuditContextSanitizer::sanitize('provider.connection.check'))
->toBe('provider.connection.check');
});
it('redacts jwt-like strings', function (): void {
$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
.'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.'
.'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(AuditContextSanitizer::sanitize($jwt))
->toBe('[REDACTED]');
});