Compare commits

..

12 Commits

Author SHA1 Message Date
Ahmed Darrazi
b13a43ba30 refactor: remove findings lifecycle backfill runtime surfaces
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 56s
## Summary
- decommission the legacy findings lifecycle backfill substrate across command, job, service, and UI layers
- remove related platform capabilities, operation catalog entries, and action surface exemptions
- add regression and removal verification tests to ensure runtime integrity and surface absence
- include spec, plan, tasks, and data-model artifacts for the removal slice

## Scope
- active spec: specs/253-remove-findings-backfill-runtime-surfaces
- target branch: dev

## Validation
- integrated regression and removal verification tests for console, findings, and system ops surfaces
- audit log and capability trace verification for the removal path
2026-04-28 23:47:16 +02:00
Ahmed Darrazi
44e6a1eb05 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 21:46:29 +02:00
Ahmed Darrazi
4f7c1a6c94 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 15:41:58 +02:00
Ahmed Darrazi
4325e1ed8d Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 12:18:08 +02:00
Ahmed Darrazi
4ae4c2ee95 chore: add gitea MCP helper script
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 58s
2026-04-28 09:26:51 +02:00
Ahmed Darrazi
32b6dcb937 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-28 09:22:09 +02:00
Ahmed Darrazi
f7bc4f2787 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 23:22:08 +02:00
Ahmed Darrazi
0739018ee5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 19:36:43 +02:00
Ahmed Darrazi
9a02261f5c Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 15:03:58 +02:00
Ahmed Darrazi
65ec1d5904 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 10:33:23 +02:00
Ahmed Darrazi
f05857c276 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 02:13:30 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
81 changed files with 541 additions and 3145 deletions

View File

@ -6,14 +6,12 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class PurgeLegacyBaselineGapRuns extends Command class PurgeLegacyBaselineGapRuns extends Command
{ {
protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs protected $signature = 'tenantpilot:baselines:purge-legacy-gap-runs
{--type=* : Limit cleanup to baseline.compare and/or baseline.capture runs (legacy aliases also accepted)} {--type=* : Limit cleanup to baseline_compare and/or baseline_capture runs}
{--tenant=* : Limit cleanup to tenant ids or tenant external ids} {--tenant=* : Limit cleanup to tenant ids or tenant external ids}
{--workspace=* : Limit cleanup to workspace ids} {--workspace=* : Limit cleanup to workspace ids}
{--limit=500 : Maximum candidate runs to inspect} {--limit=500 : Maximum candidate runs to inspect}
@ -101,35 +99,21 @@ public function handle(): int
*/ */
private function normalizedTypes(): array private function normalizedTypes(): array
{ {
$requestedTypes = array_values(array_unique(array_filter( $types = array_values(array_unique(array_filter(
array_map( array_map(
static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null, static fn (mixed $type): ?string => is_string($type) && trim($type) !== '' ? trim($type) : null,
(array) $this->option('type'), (array) $this->option('type'),
), ),
))); )));
$canonicalTypes = array_values(array_unique(array_filter(array_map( if ($types === []) {
static fn (string $type): ?string => match ($type) { return ['baseline_compare', 'baseline_capture'];
OperationRunType::BaselineCompare->value, 'baseline_compare' => OperationRunType::BaselineCompare->value,
OperationRunType::BaselineCapture->value, 'baseline_capture' => OperationRunType::BaselineCapture->value,
default => null,
},
$requestedTypes,
))));
if ($canonicalTypes === []) {
$canonicalTypes = [
OperationRunType::BaselineCompare->value,
OperationRunType::BaselineCapture->value,
];
} }
return array_values(array_unique(array_merge( return array_values(array_filter(
...array_map( $types,
static fn (string $type): array => OperationCatalog::rawValuesForCanonical($type), static fn (string $type): bool => in_array($type, ['baseline_compare', 'baseline_capture'], true),
$canonicalTypes, ));
),
)));
} }
/** /**

View File

@ -16,11 +16,6 @@
use App\Support\Findings\FindingOutcomeSemantics; use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles; use App\Support\Filament\TablePaginationProfiles;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome; use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
@ -62,16 +57,6 @@ class CustomerReviewWorkspace extends Page implements HasTable
protected string $view = 'filament.pages.reviews.customer-review-workspace'; protected string $view = 'filament.pages.reviews.customer-review-workspace';
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions provide a single Clear filters action for the customer review workspace.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The customer review workspace remains scan-first and does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state keeps exactly one Clear filters CTA when filters are active.')
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the latest published review detail instead of an inline canonical detail panel.');
}
public static function getNavigationGroup(): string public static function getNavigationGroup(): string
{ {
return __('localization.review.reporting'); return __('localization.review.reporting');

View File

@ -308,6 +308,8 @@ public static function infolist(Schema $schema): Schema
? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record)) ? OperationRunLinks::tenantlessView((int) $record->current_operation_run_id, static::findingRunNavigationContext($record))
: null) : null)
->openUrlInNewTab(), ->openUrlInNewTab(),
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'), TextEntry::make('first_seen_at')->label('First seen')->dateTime()->placeholder('—'),
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'), TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
TextEntry::make('times_seen')->label('Times seen')->placeholder('—'), TextEntry::make('times_seen')->label('Times seen')->placeholder('—'),
@ -998,6 +1000,7 @@ public static function table(Table $table): Table
if (! in_array((string) $record->status, [ if (! in_array((string) $record->status, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) { ], true)) {
$skippedCount++; $skippedCount++;
@ -1413,6 +1416,7 @@ public static function triageAction(): Actions\Action
->visible(fn (Finding $record): bool => in_array((string) $record->status, [ ->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) ], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void { ->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation( static::runWorkflowMutation(
@ -1437,6 +1441,7 @@ public static function startProgressAction(): Actions\Action
->color('info') ->color('info')
->visible(fn (Finding $record): bool => in_array((string) $record->status, [ ->visible(fn (Finding $record): bool => in_array((string) $record->status, [
Finding::STATUS_TRIAGED, Finding::STATUS_TRIAGED,
Finding::STATUS_ACKNOWLEDGED,
], true)) ], true))
->action(function (Finding $record, FindingWorkflowService $workflow): void { ->action(function (Finding $record, FindingWorkflowService $workflow): void {
static::runWorkflowMutation( static::runWorkflowMutation(

View File

@ -171,6 +171,7 @@ protected function getHeaderActions(): array
if (! in_array((string) $finding->status, [ if (! in_array((string) $finding->status, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) { ], true)) {
$skippedCount++; $skippedCount++;

View File

@ -57,6 +57,11 @@ public static function canAccess(): bool
&& $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE); && $user->hasCapability(PlatformCapabilities::OPS_CONTROLS_MANAGE);
} }
public function mount(): void
{
abort_unless(static::canAccess(), 403);
}
public function getHeader(): ?View public function getHeader(): ?View
{ {
return view('filament.system.pages.ops.partials.controls-header', [ return view('filament.system.pages.ops.partials.controls-header', [

View File

@ -1871,11 +1871,8 @@ private function upsertFindings(
} else { } else {
$this->observeFinding( $this->observeFinding(
finding: $finding, finding: $finding,
tenant: $tenant,
observedAt: $observedAt, observedAt: $observedAt,
currentOperationRunId: (int) $this->operationRun->getKey(), currentOperationRunId: (int) $this->operationRun->getKey(),
severity: (string) $driftItem['severity'],
slaPolicy: $slaPolicy,
); );
} }
@ -1950,21 +1947,12 @@ private function upsertFindings(
]; ];
} }
private function observeFinding( private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void
Finding $finding,
Tenant $tenant,
CarbonImmutable $observedAt,
int $currentOperationRunId,
string $severity,
FindingSlaPolicy $slaPolicy,
): void
{ {
if ($finding->first_seen_at === null) { if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt; $finding->first_seen_at = $observedAt;
} }
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) { if ($finding->last_seen_at === null || $observedAt->greaterThan(CarbonImmutable::instance($finding->last_seen_at))) {
$finding->last_seen_at = $observedAt; $finding->last_seen_at = $observedAt;
} }
@ -1976,14 +1964,6 @@ private function observeFinding(
} elseif ($timesSeen < 1) { } elseif ($timesSeen < 1) {
$finding->times_seen = 1; $finding->times_seen = 1;
} }
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
} }
/** /**

View File

@ -33,6 +33,8 @@ class Finding extends Model
public const string STATUS_NEW = 'new'; public const string STATUS_NEW = 'new';
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
public const string STATUS_TRIAGED = 'triaged'; public const string STATUS_TRIAGED = 'triaged';
public const string STATUS_IN_PROGRESS = 'in_progress'; public const string STATUS_IN_PROGRESS = 'in_progress';
@ -167,7 +169,10 @@ public static function terminalStatuses(): array
*/ */
public static function openStatusesForQuery(): array public static function openStatusesForQuery(): array
{ {
return self::openStatuses(); return [
...self::openStatuses(),
self::STATUS_ACKNOWLEDGED,
];
} }
/** /**
@ -290,6 +295,10 @@ public static function isReopenReason(?string $reason): bool
public static function canonicalizeStatus(?string $status): ?string public static function canonicalizeStatus(?string $status): ?string
{ {
if ($status === self::STATUS_ACKNOWLEDGED) {
return self::STATUS_TRIAGED;
}
return $status; return $status;
} }
@ -315,6 +324,23 @@ public function isRiskAccepted(): bool
return (string) $this->status === self::STATUS_RISK_ACCEPTED; return (string) $this->status === self::STATUS_RISK_ACCEPTED;
} }
public function acknowledge(User $user): self
{
if ($this->status === self::STATUS_ACKNOWLEDGED) {
return $this;
}
$this->forceFill([
'status' => self::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
$this->save();
return $this;
}
public function resolve(string $reason): self public function resolve(string $reason): self
{ {
$this->forceFill([ $this->forceFill([

View File

@ -49,7 +49,10 @@ public function update(User $user, Finding $finding): Response|bool
public function triage(User $user, Finding $finding): Response|bool public function triage(User $user, Finding $finding): Response|bool
{ {
return $this->canMutateWithCapability($user, $finding, Capabilities::TENANT_FINDINGS_TRIAGE); return $this->canMutateWithAnyCapability($user, $finding, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
} }
public function assign(User $user, Finding $finding): Response|bool public function assign(User $user, Finding $finding): Response|bool

View File

@ -28,6 +28,7 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT, Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -73,6 +74,7 @@ class RoleCapabilityMap
Capabilities::TENANT_FINDINGS_RESOLVE, Capabilities::TENANT_FINDINGS_RESOLVE,
Capabilities::TENANT_FINDINGS_CLOSE, Capabilities::TENANT_FINDINGS_CLOSE,
Capabilities::TENANT_FINDINGS_RISK_ACCEPT, Capabilities::TENANT_FINDINGS_RISK_ACCEPT,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::FINDING_EXCEPTION_MANAGE, Capabilities::FINDING_EXCEPTION_MANAGE,
Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE,
@ -110,6 +112,7 @@ class RoleCapabilityMap
Capabilities::TENANT_INVENTORY_SYNC_RUN, Capabilities::TENANT_INVENTORY_SYNC_RUN,
Capabilities::TENANT_FINDINGS_VIEW, Capabilities::TENANT_FINDINGS_VIEW,
Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
Capabilities::FINDING_EXCEPTION_VIEW, Capabilities::FINDING_EXCEPTION_VIEW,
Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW,

View File

@ -163,7 +163,7 @@ private function upsertFinding(
->first(); ->first();
if ($existing instanceof Finding) { if ($existing instanceof Finding) {
$this->observeFinding($existing, $tenant, $observedAt, $severity); $this->observeFinding($existing, $observedAt);
$existing->forceFill([ $existing->forceFill([
'severity' => $severity, 'severity' => $severity,
@ -253,7 +253,7 @@ private function handleGaAggregate(
->first(); ->first();
if ($existing instanceof Finding) { if ($existing instanceof Finding) {
$this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH); $this->observeFinding($existing, $observedAt);
$existing->forceFill([ $existing->forceFill([
'severity' => Finding::SEVERITY_HIGH, 'severity' => Finding::SEVERITY_HIGH,
@ -380,33 +380,25 @@ private function resolveSlaPolicy(): FindingSlaPolicy
return $this->slaPolicy ?? app(FindingSlaPolicy::class); return $this->slaPolicy ?? app(FindingSlaPolicy::class);
} }
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
{ {
if ($finding->first_seen_at === null) { if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt; $finding->first_seen_at = $observedAt;
} }
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at; $lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt; $finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1; $finding->times_seen = max(0, $timesSeen) + 1;
} elseif ($timesSeen < 1) {
return;
}
if ($timesSeen < 1) {
$finding->times_seen = 1; $finding->times_seen = 1;
} }
$slaPolicy = $this->resolveSlaPolicy();
if ($finding->sla_days === null) {
$finding->sla_days = $slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
} }
private function produceAlertEvent(Tenant $tenant, string $fingerprint, array $evidence): void private function produceAlertEvent(Tenant $tenant, string $fingerprint, array $evidence): void

View File

@ -46,13 +46,17 @@ public static function meaningfulActivityActionValues(): array
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
{ {
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); $this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
$currentStatus = (string) $finding->status; $currentStatus = (string) $finding->status;
if (! in_array($currentStatus, [ if (! in_array($currentStatus, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,
Finding::STATUS_ACKNOWLEDGED,
], true)) { ], true)) {
throw new InvalidArgumentException('Finding cannot be triaged from the current status.'); throw new InvalidArgumentException('Finding cannot be triaged from the current status.');
} }
@ -78,9 +82,12 @@ public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding
{ {
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); $this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
if ((string) $finding->status !== Finding::STATUS_TRIAGED) { if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) {
throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.'); throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.');
} }
@ -362,7 +369,10 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding public function reopen(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding
{ {
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_TRIAGE]); $this->authorize($finding, $tenant, $actor, [
Capabilities::TENANT_FINDINGS_TRIAGE,
Capabilities::TENANT_FINDINGS_ACKNOWLEDGE,
]);
if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) { if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) {
throw new InvalidArgumentException('Only terminal findings can be reopened.'); throw new InvalidArgumentException('Only terminal findings can be reopened.');

View File

@ -140,7 +140,7 @@ private function handleMissingPermission(
->first(); ->first();
if ($finding instanceof Finding) { if ($finding instanceof Finding) {
$this->observeFinding($finding, $tenant, $observedAt, $severity); $this->observeFinding($finding, $observedAt);
$finding->forceFill([ $finding->forceFill([
'severity' => $severity, 'severity' => $severity,
@ -216,7 +216,7 @@ private function handleErrorPermission(
->first(); ->first();
if ($existing instanceof Finding) { if ($existing instanceof Finding) {
$this->observeFinding($existing, $tenant, $observedAt, $severity); $this->observeFinding($existing, $observedAt);
$existing->forceFill([ $existing->forceFill([
'severity' => $severity, 'severity' => $severity,
@ -349,31 +349,25 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu
return CarbonImmutable::now(); return CarbonImmutable::now();
} }
private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): void private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void
{ {
if ($finding->first_seen_at === null) { if ($finding->first_seen_at === null) {
$finding->first_seen_at = $observedAt; $finding->first_seen_at = $observedAt;
} }
$firstSeenAt = CarbonImmutable::instance($finding->first_seen_at);
$lastSeenAt = $finding->last_seen_at; $lastSeenAt = $finding->last_seen_at;
$timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0; $timesSeen = is_numeric($finding->times_seen) ? (int) $finding->times_seen : 0;
if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) { if ($lastSeenAt === null || $observedAt->greaterThan(CarbonImmutable::instance($lastSeenAt))) {
$finding->last_seen_at = $observedAt; $finding->last_seen_at = $observedAt;
$finding->times_seen = max(0, $timesSeen) + 1; $finding->times_seen = max(0, $timesSeen) + 1;
} elseif ($timesSeen < 1) {
return;
}
if ($timesSeen < 1) {
$finding->times_seen = 1; $finding->times_seen = 1;
} }
if ($finding->sla_days === null) {
$finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
}
} }
/** /**

View File

@ -128,7 +128,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
{ {
$summary = $this->summary($findingsItem); $summary = $this->summary($findingsItem);
$entries = collect(Arr::wrap($summary['entries'] ?? [])) $entries = collect(Arr::wrap($summary['entries'] ?? []))
->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'new', 'triaged', 'in_progress', 'reopened'], true)) ->filter(static fn (mixed $entry): bool => is_array($entry) && in_array((string) ($entry['status'] ?? ''), ['open', 'in_progress', 'acknowledged'], true))
->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) { ->sortByDesc(static fn (array $entry): int => match ((string) ($entry['severity'] ?? 'low')) {
'critical' => 4, 'critical' => 4,
'high' => 3, 'high' => 3,

View File

@ -91,6 +91,8 @@ class Capabilities
public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept'; public const TENANT_FINDINGS_RISK_ACCEPT = 'tenant_findings.risk_accept';
public const TENANT_FINDINGS_ACKNOWLEDGE = 'tenant_findings.acknowledge';
public const FINDING_EXCEPTION_VIEW = 'finding_exception.view'; public const FINDING_EXCEPTION_VIEW = 'finding_exception.view';
public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage'; public const FINDING_EXCEPTION_MANAGE = 'finding_exception.manage';

View File

@ -796,6 +796,7 @@ private static function findingAttentionCounts(Tenant $tenant): array
$activeNonNewFindingsCount = Finding::query() $activeNonNewFindingsCount = Finding::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->whereIn('status', [ ->whereIn('status', [
Finding::STATUS_ACKNOWLEDGED,
Finding::STATUS_TRIAGED, Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS, Finding::STATUS_IN_PROGRESS,
Finding::STATUS_REOPENED, Finding::STATUS_REOPENED,

View File

@ -99,7 +99,7 @@ private static function resolveTenantWorkspaceId(Model $model, int $tenantId): i
$tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null; $tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null;
if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) { if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) {
$tenant = Tenant::query()->withTrashed()->find($tenantId); $tenant = Tenant::query()->find($tenantId);
} }
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {

View File

@ -103,9 +103,9 @@ public static function baselineProfileStatuses(): array
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
public static function findingStatuses(): array public static function findingStatuses(bool $includeLegacyAcknowledged = true): array
{ {
return self::badgeOptions(BadgeDomain::FindingStatus, [ $options = self::badgeOptions(BadgeDomain::FindingStatus, [
Finding::STATUS_NEW, Finding::STATUS_NEW,
Finding::STATUS_TRIAGED, Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS, Finding::STATUS_IN_PROGRESS,
@ -114,6 +114,21 @@ public static function findingStatuses(): array
Finding::STATUS_CLOSED, Finding::STATUS_CLOSED,
Finding::STATUS_RISK_ACCEPTED, Finding::STATUS_RISK_ACCEPTED,
]); ]);
if (! $includeLegacyAcknowledged) {
return $options;
}
return [
Finding::STATUS_NEW => $options[Finding::STATUS_NEW],
Finding::STATUS_TRIAGED => $options[Finding::STATUS_TRIAGED],
Finding::STATUS_ACKNOWLEDGED => self::legacyFindingAcknowledgedLabel(),
Finding::STATUS_IN_PROGRESS => $options[Finding::STATUS_IN_PROGRESS],
Finding::STATUS_REOPENED => $options[Finding::STATUS_REOPENED],
Finding::STATUS_RESOLVED => $options[Finding::STATUS_RESOLVED],
Finding::STATUS_CLOSED => $options[Finding::STATUS_CLOSED],
Finding::STATUS_RISK_ACCEPTED => $options[Finding::STATUS_RISK_ACCEPTED],
];
} }
/** /**
@ -297,6 +312,11 @@ private static function badgeOptions(BadgeDomain $domain, array $values): array
->all(); ->all();
} }
private static function legacyFindingAcknowledgedLabel(): string
{
return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)';
}
private static function platformLabel(string $platform): string private static function platformLabel(string $platform): string
{ {
return match (Str::of($platform) return match (Str::of($platform)

View File

@ -289,36 +289,27 @@ private static function operationAliases(): array
return [ return [
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true), new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'), new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', false, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
new OperationTypeAlias('policy.snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true), new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true), new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
new OperationTypeAlias('policy.restore', 'policy.restore', 'canonical', true),
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'), new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', false, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true), new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true), new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
new OperationTypeAlias('inventory.sync', 'inventory.sync', 'canonical', true),
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'), new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', false, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'), new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true), new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'), new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
new OperationTypeAlias('directory.groups.sync', 'directory.groups.sync', 'canonical', true),
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'), new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', false, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true), new OperationTypeAlias('backup_set.update', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.archive', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true), new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true), new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'), new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', false, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
new OperationTypeAlias('backup.schedule.execute', 'backup.schedule.execute', 'canonical', true),
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'), new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', false, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
new OperationTypeAlias('backup.schedule.retention', 'backup.schedule.retention', 'canonical', true),
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'), new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', false, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('backup.schedule.purge', 'backup.schedule.purge', 'canonical', true),
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'), new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', false, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true), new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true), new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true), new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true), new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
new OperationTypeAlias('directory.role_definitions.sync', 'directory.role_definitions.sync', 'canonical', true),
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'), new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', false, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true), new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true), new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
@ -333,7 +324,6 @@ private static function operationAliases(): array
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true), new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'), new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', false, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'), new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', false, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
new OperationTypeAlias('permission.posture.check', 'permission.posture.check', 'canonical', true),
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'), new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', false, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true), new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true), new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),

View File

@ -73,6 +73,18 @@ public function permissionPosture(): static
]); ]);
} }
/**
* State for legacy acknowledged findings.
*/
public function acknowledged(): static
{
return $this->state(fn (array $attributes): array => [
'status' => Finding::STATUS_ACKNOWLEDGED,
'acknowledged_at' => now(),
'acknowledged_by_user_id' => null,
]);
}
/** /**
* State for triaged findings. * State for triaged findings.
*/ */

View File

@ -1,10 +1,7 @@
@php @php
use App\Support\Verification\VerificationLinkBehavior;
$help = is_array($help ?? null) ? $help : []; $help = is_array($help ?? null) ? $help : [];
$links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : []; $links = is_array($help['docs_links'] ?? null) ? $help['docs_links'] : [];
$steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : []; $steps = is_array($help['troubleshooting_steps'] ?? null) ? $help['troubleshooting_steps'] : [];
$linkBehavior = app(VerificationLinkBehavior::class);
$headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== '' $headline = is_string($help['headline'] ?? null) && trim((string) ($help['headline'] ?? '')) !== ''
? (string) ($help['headline']) ? (string) ($help['headline'])
: 'Contextual help'; : 'Contextual help';
@ -60,16 +57,9 @@
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@foreach ($links as $link) @foreach ($links as $link)
@php @php
$linkLabel = is_string($link['label'] ?? null) && trim((string) ($link['label'] ?? '')) !== ''
? (string) $link['label']
: 'Open';
$linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== '' $linkUrl = is_string($link['url'] ?? null) && trim((string) ($link['url'] ?? '')) !== ''
? (string) $link['url'] ? (string) $link['url']
: null; : null;
$behavior = $linkUrl !== null
? $linkBehavior->describe($linkLabel, $linkUrl)
: null;
$testId = 'contextual-help-link-'.\Illuminate\Support\Str::slug($linkLabel);
@endphp @endphp
@if ($linkUrl) @if ($linkUrl)
@ -78,11 +68,8 @@
:href="$linkUrl" :href="$linkUrl"
size="sm" size="sm"
color="primary" color="primary"
:target="(bool) ($behavior['opens_in_new_tab'] ?? false) ? '_blank' : null"
:rel="(bool) ($behavior['opens_in_new_tab'] ?? false) ? 'noopener noreferrer' : null"
:data-testid="$testId"
> >
{{ $linkLabel }} {{ (string) ($link['label'] ?? 'Open') }}
</x-filament::button> </x-filament::button>
@endif @endif
@endforeach @endforeach

View File

@ -61,6 +61,18 @@
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$visibleSelectValue = <<<'JS'
(() => {
const select = [...document.querySelectorAll('select')].find((element) => {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
return select?.value ?? null;
})()
JS;
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$page $page
@ -75,8 +87,8 @@
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Verify access') ->assertSee('Verify access')
->assertSee('Status: Not started') ->assertSee('Status: Not started')
->click('Select an existing connection or create a new one.') ->click('Provider connection')
->assertSee('Edit selected connection') ->assertScript($visibleSelectValue, (string) $connection->getKey())
->click('Create new connection') ->click('Create new connection')
->check('internal:label="Dedicated override"s') ->check('internal:label="Dedicated override"s')
->fill('[type="password"]', 'browser-only-secret') ->fill('[type="password"]', 'browser-only-secret')
@ -85,8 +97,8 @@
->waitForText('Status: Not started') ->waitForText('Status: Not started')
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Verify access') ->assertSee('Verify access')
->click('Select an existing connection or create a new one.') ->click('Provider connection')
->assertSee('Edit selected connection') ->assertScript($visibleSelectValue, (string) $connection->getKey())
->click('Create new connection') ->click('Create new connection')
->check('internal:label="Dedicated override"s') ->check('internal:label="Dedicated override"s')
->assertValue('[type="password"]', ''); ->assertValue('[type="password"]', '');

View File

@ -86,6 +86,18 @@
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$visibleSelectValue = <<<'JS'
(() => {
const select = [...document.querySelectorAll('select')].find((element) => {
const style = window.getComputedStyle(element);
return style.display !== 'none' && style.visibility !== 'hidden';
});
return select?.value ?? null;
})()
JS;
$page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])); $page = visit(route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]));
$page $page
@ -101,8 +113,8 @@
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->assertSee('Status: Needs attention') ->assertSee('Status: Needs attention')
->assertSee('Start verification') ->assertSee('Start verification')
->click('Select an existing connection or create a new one.') ->click('Provider connection')
->assertSee('Edit selected connection'); ->assertScript($visibleSelectValue, (string) $selectedConnection->getKey());
}); });
it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void { it('preserves bootstrap revisit state and blocked activation guards after refresh', function (): void {
@ -316,14 +328,32 @@
->assertNoJavaScriptErrors() ->assertNoJavaScriptErrors()
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->wait(1) ->wait(1)
->assertScript("document.querySelector('[data-testid=\"contextual-help-link-open-required-permissions\"]') !== null", true) ->assertScript("document.querySelector('[data-testid=\"verification-assist-trigger\"]') !== null", true)
->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'target', '_blank') ->click('[data-testid="verification-assist-trigger"]')
->assertAttribute('[data-testid="contextual-help-link-open-required-permissions"]', 'rel', 'noopener noreferrer') ->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
->click('[data-testid="contextual-help-link-open-required-permissions"]') ->assertAttribute('[data-testid="verification-assist-full-page"]', 'target', '_blank');
$page->script(<<<'JS'
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: async () => Promise.resolve(),
},
});
document.querySelector('[data-testid="verification-assist-copy-application"]')?.click();
JS);
$page
->waitForText('Copied')
->assertAttribute('[data-testid="verification-assist-full-page"]', 'rel', 'noopener noreferrer')
->click('[data-testid="verification-assist-full-page"]')
->wait(1) ->wait(1)
->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]) ->assertRoute('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()])
->click('Select an existing connection or create a new one.') ->assertScript("document.querySelector('[data-testid=\"verification-assist-root\"]') !== null", true)
->assertSee('Edit selected connection'); ->click('Close')
->click('Provider connection')
->assertSee('Select an existing connection or create a new one.');
}); });
it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void { it('opens the permissions assist from report remediation steps without leaving onboarding', function (): void {

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
uses(RefreshDatabase::class);
it('removes the acknowledged findings capability alias from shared RBAC truth', function (): void {
expect(Capabilities::isKnown('tenant_findings.acknowledge'))->toBeFalse();
expect(RoleCapabilityMap::rolesWithCapability('tenant_findings.acknowledge'))->toBe([]);
});
it('keeps the canonical findings triage capability available to operators', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'operator');
expect(RoleCapabilityMap::hasCapability(TenantRole::Operator, Capabilities::TENANT_FINDINGS_TRIAGE))->toBeTrue();
expect(Gate::forUser($user)->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue();
});

View File

@ -9,7 +9,6 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\OperationRunType;
it('writes audit events for baseline capture start and completion with scope + gap summary', function () { it('writes audit events for baseline capture start and completion with scope + gap summary', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -36,7 +35,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -58,7 +58,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -12,7 +12,6 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () { it('Baseline capture stores content fidelity hash when PolicyVersion evidence exists', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -74,7 +73,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -18,7 +18,6 @@
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\Baselines\PolicyVersionCapturePurpose; use App\Support\Baselines\PolicyVersionCapturePurpose;
use App\Support\OperationRunType;
it('Baseline capture (full content) captures evidence on demand when missing', function () { it('Baseline capture (full content) captures evidence on demand when missing', function () {
config()->set('tenantpilot.baselines.full_content_capture.enabled', true); config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
@ -120,7 +119,7 @@ public function capture(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -64,7 +64,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -14,7 +14,6 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void { it('captures intune role definitions with identity metadata and excludes role assignments from the baseline snapshot', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -105,7 +104,7 @@
$operationRuns = app(OperationRunService::class); $operationRuns = app(OperationRunService::class);
$run = $operationRuns->ensureRunWithIdentity( $run = $operationRuns->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -17,7 +17,6 @@
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSnapshotLifecycleState; use App\Support\Baselines\BaselineSnapshotLifecycleState;
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
function createBaselineCaptureInventoryBasis( function createBaselineCaptureInventoryBasis(
@ -66,7 +65,7 @@ function runBaselineCaptureJob(
/** @var OperationRun $run */ /** @var OperationRun $run */
$run = $result['run']; $run = $result['run'];
expect($run->type)->toBe(OperationRunType::BaselineCapture->value); expect($run->type)->toBe('baseline_capture');
expect($run->status)->toBe('queued'); expect($run->status)->toBe('queued');
expect($run->tenant_id)->toBe((int) $tenant->getKey()); expect($run->tenant_id)->toBe((int) $tenant->getKey());
@ -105,7 +104,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('rejects capture when the latest inventory sync was blocked', function () { it('rejects capture when the latest inventory sync was blocked', function () {
@ -136,7 +135,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () { it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
@ -167,7 +166,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () { it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
@ -190,7 +189,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE); expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('rejects capture for a draft profile with reason code', function () { it('rejects capture for a draft profile with reason code', function () {
@ -210,7 +209,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('rejects capture for an archived profile with reason code', function () { it('rejects capture for an archived profile with reason code', function () {
@ -229,7 +228,7 @@ function runBaselineCaptureJob(
expect($result['reason_code'])->toBe('baseline.capture.profile_not_active'); expect($result['reason_code'])->toBe('baseline.capture.profile_not_active');
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('rejects capture for a tenant from a different workspace', function () { it('rejects capture for a tenant from a different workspace', function () {
@ -275,7 +274,7 @@ function runBaselineCaptureJob(
expect($result2['ok'])->toBeTrue(); expect($result2['ok'])->toBeTrue();
expect($result1['run']->getKey())->toBe($result2['run']->getKey()); expect($result1['run']->getKey())->toBe($result2['run']->getKey());
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(1); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(1);
}); });
// --- Snapshot dedupe + capture job execution --- // --- Snapshot dedupe + capture job execution ---
@ -322,7 +321,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
@ -477,7 +476,7 @@ function runBaselineCaptureJob(
$run1 = $opService->ensureRunWithIdentity( $run1 = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
@ -500,7 +499,7 @@ function runBaselineCaptureJob(
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(), 'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name, 'initiator_name' => $user->name,
'type' => OperationRunType::BaselineCapture->value, 'type' => 'baseline_capture',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp), 'run_identity_hash' => hash('sha256', 'second-run-'.now()->timestamp),
@ -587,7 +586,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
@ -663,7 +662,7 @@ function runBaselineCaptureJob(
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$run = $opService->ensureRunWithIdentity( $run = $opService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -528,133 +528,6 @@
expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1); expect((string) data_get($finding->evidence_jsonb, 'current.hash'))->not->toBe($currentHash1);
}); });
it('repairs missing due-state fields on an existing open drift finding without extending the original due date', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$snapshot = BaselineSnapshot::factory()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
$builder = app(InventoryMetaContract::class);
$hasher = app(DriftHasher::class);
$baselineContract = $builder->build(
policyType: 'deviceConfiguration',
subjectExternalId: 'policy-x-uuid',
metaJsonb: ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_BASELINE'],
);
$displayName = 'Policy X';
$subjectKey = BaselineSubjectKey::fromDisplayName($displayName);
expect($subjectKey)->not->toBeNull();
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId('deviceConfiguration', (string) $subjectKey);
BaselineSnapshotItem::factory()->create([
'baseline_snapshot_id' => $snapshot->getKey(),
'subject_type' => 'policy',
'subject_external_id' => $workspaceSafeExternalId,
'subject_key' => (string) $subjectKey,
'policy_type' => 'deviceConfiguration',
'baseline_hash' => $hasher->hashNormalized($baselineContract),
'meta_jsonb' => ['display_name' => $displayName],
]);
InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'external_id' => 'policy-x-uuid',
'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_CURRENT_1'],
'display_name' => $displayName,
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$opService = app(OperationRunService::class);
$run1 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run1))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$scopeKey = 'baseline_profile:'.$profile->getKey();
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('source', 'baseline.compare')
->where('scope_key', $scopeKey)
->sole();
$expectedSlaDays = (int) $finding->sla_days;
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($expectedSlaDays)->toBeGreaterThan(0)
->and($expectedDueAt)->not->toBeNull();
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
\Carbon\CarbonImmutable::setTestNow(\Carbon\CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$run2 = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: OperationRunType::BaselineCompare->value,
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'baseline_snapshot_id' => (int) $snapshot->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
],
initiator: $user,
);
(new CompareBaselineToTenantJob($run2))->handle(
app(BaselineSnapshotIdentity::class),
app(AuditLogger::class),
$opService,
);
$finding->refresh();
expect($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe($expectedSlaDays)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
\Carbon\CarbonImmutable::setTestNow();
});
it('does not create new finding identities when a new snapshot is captured', function () { it('does not create new finding identities when a new snapshot is captured', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -15,7 +15,6 @@
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Livewire; use Livewire\Livewire;
@ -70,14 +69,14 @@
$activeRuns = OperationRun::query() $activeRuns = OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey()) ->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', OperationRunType::BaselineCompare->value) ->where('type', 'baseline_compare')
->get(); ->get();
expect($activeRuns)->toHaveCount(2) expect($activeRuns)->toHaveCount(2)
->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => $run->tenant_id !== null))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->status === OperationRunStatus::Queued->value))->toBeTrue()
->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue() ->and($activeRuns->every(static fn (OperationRun $run): bool => (string) $run->outcome === OperationRunOutcome::Pending->value))->toBeTrue()
->and(OperationRun::query()->whereNull('tenant_id')->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); ->and(OperationRun::query()->whereNull('tenant_id')->where('type', 'baseline_compare')->count())->toBe(0);
}); });
it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void { it('runs compare assigned tenants from the matrix page and keeps feedback on tenant-owned runs', function (): void {
@ -98,7 +97,7 @@
expect(OperationRun::query() expect(OperationRun::query()
->where('workspace_id', (int) $fixture['workspace']->getKey()) ->where('workspace_id', (int) $fixture['workspace']->getKey())
->where('type', OperationRunType::BaselineCompare->value) ->where('type', 'baseline_compare')
->whereNull('tenant_id') ->whereNull('tenant_id')
->count())->toBe(0); ->count())->toBe(0);
}); });

View File

@ -3,7 +3,6 @@
use App\Jobs\EntraGroupSyncJob; use App\Jobs\EntraGroupSyncJob;
use App\Services\Directory\EntraGroupSyncService; use App\Services\Directory\EntraGroupSyncService;
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
it('starts a manual group sync by creating a run and dispatching a job', function () { it('starts a manual group sync by creating a run and dispatching a job', function () {
@ -22,7 +21,7 @@
expect($run) expect($run)
->and($run->tenant_id)->toBe($tenant->getKey()) ->and($run->tenant_id)->toBe($tenant->getKey())
->and($run->user_id)->toBe($user->getKey()) ->and($run->user_id)->toBe($user->getKey())
->and($run->type)->toBe(OperationRunType::DirectoryGroupsSync->value) ->and($run->type)->toBe('entra_group_sync')
->and($run->status)->toBe('queued') ->and($run->status)->toBe('queued')
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all') ->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all')
->and($run->context['provider_connection_id'] ?? null)->toBeInt(); ->and($run->context['provider_connection_id'] ?? null)->toBeInt();

View File

@ -7,7 +7,6 @@
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunType;
it('sync job upserts groups and updates run counters', function () { it('sync job upserts groups and updates run counters', function () {
@ -55,7 +54,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::DirectoryGroupsSync->value, type: 'entra_group_sync',
inputs: ['selection_key' => 'groups-v1:all'], inputs: ['selection_key' => 'groups-v1:all'],
initiator: $user, initiator: $user,
); );

View File

@ -7,7 +7,6 @@
use App\Services\Graph\GraphResponse; use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunType;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
it('purges cached groups older than the retention window', function () { it('purges cached groups older than the retention window', function () {
@ -35,7 +34,7 @@
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
$opRun = $opService->ensureRun( $opRun = $opService->ensureRun(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::DirectoryGroupsSync->value, type: 'entra_group_sync',
inputs: ['selection_key' => 'groups-v1:all'], inputs: ['selection_key' => 'groups-v1:all'],
initiator: $user, initiator: $user,
); );

View File

@ -195,54 +195,6 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00'); ->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00');
}); });
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
[$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$generator->generate($tenant, buildPayload(
[gaRoleDef()],
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
'2026-02-24T10:00:00Z',
));
$finding = Finding::query()
->where('tenant_id', $tenant->getKey())
->where('finding_type', Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES)
->firstOrFail();
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($finding->sla_days)->toBe(3)
->and($expectedDueAt)->toBe('2026-02-27T10:00:00+00:00');
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$result = $generator->generate($tenant, buildPayload(
[gaRoleDef()],
[makeEntraAssignment('a1', 'def-ga', 'user-1')],
'2026-02-24T11:00:00Z',
));
$finding->refresh();
expect($result->created)->toBe(0)
->and($result->unchanged)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_NEW)
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe(3)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
CarbonImmutable::setTestNow();
});
it('auto-resolves when assignment is removed', function (): void { it('auto-resolves when assignment is removed', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
@ -492,7 +444,7 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
->and($finding->subject_external_id)->toBe('user-1:def-ga'); ->and($finding->subject_external_id)->toBe('user-1:def-ga');
}); });
it('auto-resolve applies to triaged findings too', function (): void { it('auto-resolve applies to acknowledged findings too', function (): void {
[$user, $tenant] = createMinimalUserWithTenant(); [$user, $tenant] = createMinimalUserWithTenant();
$generator = makeGenerator(); $generator = makeGenerator();
@ -504,19 +456,20 @@ function makeGenerator(): EntraAdminRolesFindingGenerator
); );
$generator->generate($tenant, $payload); $generator->generate($tenant, $payload);
// Triage the finding // Acknowledge the finding
$finding = Finding::query() $finding = Finding::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('subject_external_id', 'user-1:def-ga') ->where('subject_external_id', 'user-1:def-ga')
->first(); ->first();
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_TRIAGED, 'status' => Finding::STATUS_ACKNOWLEDGED,
'triaged_at' => now(), 'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
])->save(); ])->save();
expect($finding->fresh()->status)->toBe(Finding::STATUS_TRIAGED); expect($finding->fresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
// Scan 2: remove -> should auto-resolve even though triaged // Scan 2: remove → should auto-resolve even though acknowledged
$payload2 = buildPayload([gaRoleDef()], []); $payload2 = buildPayload([gaRoleDef()], []);
$result = $generator->generate($tenant, $payload2); $result = $generator->generate($tenant, $payload2);

View File

@ -115,7 +115,7 @@
$run = OperationRun::query() $run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::BaselineCompare->value) ->where('type', 'baseline_compare')
->latest('id') ->latest('id')
->first(); ->first();
@ -192,7 +192,7 @@
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class); Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
}); });
it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void { it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void {
@ -250,7 +250,7 @@
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class); Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
}); });
it('can refresh stats without calling mount directly', function (): void { it('can refresh stats without calling mount directly', function (): void {

View File

@ -9,7 +9,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -122,7 +121,7 @@ function seedCaptureProfileForTenant(
$run = OperationRun::query() $run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::BaselineCapture->value) ->where('type', 'baseline_capture')
->latest('id') ->latest('id')
->first(); ->first();
@ -152,7 +151,7 @@ function seedCaptureProfileForTenant(
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('does not start full-content capture when rollout is disabled', function (): void { it('does not start full-content capture when rollout is disabled', function (): void {
@ -175,7 +174,7 @@ function seedCaptureProfileForTenant(
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void { it('shows readiness copy without exposing raw canonical scope json on the capture start surface', function (): void {
@ -229,5 +228,5 @@ function seedCaptureProfileForTenant(
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class); Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCapture->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });

View File

@ -14,7 +14,6 @@
use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy; use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -93,7 +92,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
$run = OperationRun::query() $run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::BaselineCompare->value) ->where('type', 'baseline_compare')
->latest('id') ->latest('id')
->first(); ->first();
@ -121,7 +120,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class); Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
}); });
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void { it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
@ -168,7 +167,7 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class); Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
}); });
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void { it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
@ -276,5 +275,5 @@ function seedComparableBaselineProfileForTenant(Tenant $tenant, BaselineCaptureM
->assertStatus(200); ->assertStatus(200);
Queue::assertNotPushed(CompareBaselineToTenantJob::class); Queue::assertNotPushed(CompareBaselineToTenantJob::class);
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0); expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
}); });

View File

@ -34,6 +34,7 @@ protected function makeFindingForWorkflow(Tenant $tenant, string $status = Findi
$factory = Finding::factory()->for($tenant); $factory = Finding::factory()->for($tenant);
$factory = match ($status) { $factory = match ($status) {
Finding::STATUS_ACKNOWLEDGED => $factory->acknowledged(),
Finding::STATUS_TRIAGED => $factory->triaged(), Finding::STATUS_TRIAGED => $factory->triaged(),
Finding::STATUS_IN_PROGRESS => $factory->inProgress(), Finding::STATUS_IN_PROGRESS => $factory->inProgress(),
Finding::STATUS_REOPENED => $factory->reopened(), Finding::STATUS_REOPENED => $factory->reopened(),

View File

@ -22,8 +22,9 @@
->toContain(AuditActionId::FindingReopened->value); ->toContain(AuditActionId::FindingReopened->value);
}); });
it('keeps only the surviving model lifecycle helpers', function (): void { it('keeps only legacy compatibility lifecycle helpers on the model', function (): void {
expect(method_exists(Finding::class, 'resolve'))->toBeTrue() expect(method_exists(Finding::class, 'acknowledge'))->toBeTrue()
->and(method_exists(Finding::class, 'resolve'))->toBeTrue()
->and(method_exists(Finding::class, 'reopen'))->toBeTrue() ->and(method_exists(Finding::class, 'reopen'))->toBeTrue()
->and(method_exists(Finding::class, 'triage'))->toBeFalse() ->and(method_exists(Finding::class, 'triage'))->toBeFalse()
->and(method_exists(Finding::class, 'startProgress'))->toBeFalse() ->and(method_exists(Finding::class, 'startProgress'))->toBeFalse()

View File

@ -101,10 +101,8 @@ function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
'assignee_user_id' => (int) $otherAssignee->getKey(), 'assignee_user_id' => (int) $otherAssignee->getKey(),
]); ]);
$acknowledged = Finding::factory()->for($tenantA)->create([ $acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
'workspace_id' => (int) $tenantA->workspace_id, 'workspace_id' => (int) $tenantA->workspace_id,
'status' => 'acknowledged',
'acknowledged_at' => now(),
'assignee_user_id' => null, 'assignee_user_id' => null,
'subject_external_id' => 'acknowledged', 'subject_external_id' => 'acknowledged',
]); ]);

View File

@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Services\Findings\FindingWorkflowService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('rejects triage from the removed acknowledged status', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'status' => 'acknowledged',
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
expect(fn () => app(FindingWorkflowService::class)->triage($finding, $tenant, $user))
->toThrow(\InvalidArgumentException::class, 'Finding cannot be triaged from the current status.');
});
it('rejects start progress from the removed acknowledged status', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'status' => 'acknowledged',
'triaged_at' => now()->subMinute(),
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
expect(fn () => app(FindingWorkflowService::class)->startProgress($finding, $tenant, $user))
->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.');
});

View File

@ -59,18 +59,15 @@
]); ]);
}); });
it('keeps stale acknowledged metadata as passive data only', function (): void { it('supports legacy model helper compatibility for acknowledge', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->permissionPosture()->create([ $finding = Finding::factory()->for($tenant)->permissionPosture()->create();
'status' => 'acknowledged',
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(),
]);
expect($finding->status)->toBe('acknowledged') $finding->acknowledge($user);
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED)
->and($finding->acknowledged_at)->not->toBeNull() ->and($finding->acknowledged_at)->not->toBeNull()
->and($finding->acknowledged_by_user_id)->toBe($user->getKey()) ->and($finding->acknowledged_by_user_id)->toBe($user->getKey());
->and($finding->hasOpenStatus())->toBeFalse();
}); });
it('exposes v2 open and terminal status helpers', function (): void { it('exposes v2 open and terminal status helpers', function (): void {
@ -87,26 +84,31 @@
Finding::STATUS_RISK_ACCEPTED, Finding::STATUS_RISK_ACCEPTED,
]); ]);
expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses()); expect(Finding::openStatusesForQuery())->toContain(Finding::STATUS_ACKNOWLEDGED);
}); });
it('does not treat acknowledged as canonical in v2 helpers', function (): void { it('maps legacy acknowledged status to triaged in v2 helpers', function (): void {
expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged'); expect(Finding::canonicalizeStatus(Finding::STATUS_ACKNOWLEDGED))
->toBe(Finding::STATUS_TRIAGED);
expect(Finding::isOpenStatus('acknowledged'))->toBeFalse(); expect(Finding::isOpenStatus(Finding::STATUS_ACKNOWLEDGED))->toBeTrue();
expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse(); expect(Finding::isTerminalStatus(Finding::STATUS_ACKNOWLEDGED))->toBeFalse();
}); });
it('rejects resolving a stale acknowledged finding', function (): void { it('preserves acknowledged metadata when resolving an acknowledged finding', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->permissionPosture()->create([ $finding = Finding::factory()->for($tenant)->permissionPosture()->acknowledged()->create([
'status' => 'acknowledged',
'acknowledged_at' => now(),
'acknowledged_by_user_id' => $user->getKey(), 'acknowledged_by_user_id' => $user->getKey(),
]); ]);
expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED)) expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
->toThrow(\InvalidArgumentException::class, 'Only open findings can be resolved.');
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
->and($finding->acknowledged_at)->not->toBeNull()
->and($finding->acknowledged_by_user_id)->toBe($user->getKey())
->and($finding->resolved_at)->not->toBeNull();
}); });
it('has STATUS_RESOLVED constant', function (): void { it('has STATUS_RESOLVED constant', function (): void {

View File

@ -14,7 +14,6 @@
use App\Support\Audit\AuditOutcome; use App\Support\Audit\AuditOutcome;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunType;
it('derives summary-first audit semantics for baseline capture workflow events', function (): void { it('derives summary-first audit semantics for baseline capture workflow events', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -37,7 +36,7 @@
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$run = $operationRunService->ensureRunWithIdentity( $run = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
@ -98,7 +97,7 @@
$operationRunService = app(OperationRunService::class); $operationRunService = app(OperationRunService::class);
$run = $operationRunService->ensureRunWithIdentity( $run = $operationRunService->ensureRunWithIdentity(
tenant: $tenant, tenant: $tenant,
type: OperationRunType::BaselineCapture->value, type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()], identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),

View File

@ -26,7 +26,7 @@
->get('/admin/operations') ->get('/admin/operations')
->assertOk() ->assertOk()
->assertSee($workspaceName ?? 'Select workspace') ->assertSee($workspaceName ?? 'Select workspace')
->assertSee(__('localization.shell.search_tenants')) ->assertSee('Search tenants…')
->assertSee('Switch workspace') ->assertSee('Switch workspace')
->assertSee('admin/select-tenant') ->assertSee('admin/select-tenant')
->assertSee('Clear tenant scope') ->assertSee('Clear tenant scope')
@ -66,7 +66,7 @@
->get('/admin/workspaces') ->get('/admin/workspaces')
->assertOk() ->assertOk()
->assertSee('Choose a workspace first.') ->assertSee('Choose a workspace first.')
->assertDontSee(__('localization.shell.search_tenants')); ->assertDontSee('Search tenants…');
}); });
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void { it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {

View File

@ -93,7 +93,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'tableFilters' => [ 'tableFilters' => [
'type' => [ 'type' => [
'value' => 'inventory.sync', 'value' => 'inventory_sync',
], ],
], ],
])); ]));

View File

@ -6,7 +6,6 @@
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\OperationRunType;
it('completes backup retention runs without persisting terminal notifications for system runs', function (): void { it('completes backup retention runs without persisting terminal notifications for system runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [$user, $tenant] = createUserWithTenant(role: 'manager');
@ -63,7 +62,7 @@
$retentionRun = OperationRun::query() $retentionRun = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::BackupScheduleRetention->value) ->where('type', 'backup_schedule_retention')
->latest('id') ->latest('id')
->first(); ->first();

View File

@ -104,17 +104,19 @@ function errorPermission(string $key, array $features = []): array
->and($finding->resolved_reason)->toBe('permission_granted'); ->and($finding->resolved_reason)->toBe('permission_granted');
}); });
// (3) Auto-resolves triaged finding preserving triaged metadata // (3) Auto-resolves acknowledged finding preserving metadata
it('auto-resolves triaged finding preserving triaged metadata', function (): void { it('auto-resolves acknowledged finding preserving acknowledged metadata', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
$generator = app(PermissionPostureFindingGenerator::class); $generator = app(PermissionPostureFindingGenerator::class);
$generator->generate($tenant, buildComparison([missingPermission('Perm.A')])); $generator->generate($tenant, buildComparison([missingPermission('Perm.A')]));
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first(); $finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
$ackUser = User::factory()->create();
$finding->forceFill([ $finding->forceFill([
'status' => Finding::STATUS_TRIAGED, 'status' => Finding::STATUS_ACKNOWLEDGED,
'triaged_at' => now(), 'acknowledged_at' => now(),
'acknowledged_by_user_id' => $ackUser->getKey(),
])->save(); ])->save();
$result = $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted')); $result = $generator->generate($tenant, buildComparison([grantedPermission('Perm.A')], 'granted'));
@ -122,7 +124,8 @@ function errorPermission(string $key, array $features = []): array
$finding->refresh(); $finding->refresh();
expect($result->findingsResolved)->toBe(1) expect($result->findingsResolved)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_RESOLVED) ->and($finding->status)->toBe(Finding::STATUS_RESOLVED)
->and($finding->triaged_at)->not->toBeNull(); ->and($finding->acknowledged_at)->not->toBeNull()
->and($finding->acknowledged_by_user_id)->toBe($ackUser->getKey());
}); });
// (4) No duplicates on idempotent run // (4) No duplicates on idempotent run
@ -149,45 +152,6 @@ function errorPermission(string $key, array $features = []): array
CarbonImmutable::setTestNow(); CarbonImmutable::setTestNow();
}); });
it('repairs missing due-state fields on an existing open finding without extending the original due date', function (): void {
[$user, $tenant] = createUserWithTenant();
$generator = app(PermissionPostureFindingGenerator::class);
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z'));
$generator->generate($tenant, buildComparison([
missingPermission('Perm.A', ['policy-sync', 'backup']),
]));
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->firstOrFail();
$expectedDueAt = $finding->due_at?->toIso8601String();
expect($finding->sla_days)->toBe(7)
->and($expectedDueAt)->toBe('2026-03-03T10:00:00+00:00');
$finding->forceFill([
'sla_days' => null,
'due_at' => null,
])->save();
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T11:00:00Z'));
$result = $generator->generate($tenant, buildComparison([
missingPermission('Perm.A', ['policy-sync', 'backup']),
]));
$finding->refresh();
expect($result->findingsCreated)->toBe(0)
->and($result->findingsUnchanged)->toBe(1)
->and($finding->status)->toBe(Finding::STATUS_NEW)
->and($finding->first_seen_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
->and($finding->last_seen_at?->toIso8601String())->toBe('2026-02-24T11:00:00+00:00')
->and($finding->times_seen)->toBe(2)
->and($finding->sla_days)->toBe(7)
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt);
CarbonImmutable::setTestNow();
});
// (5) Re-opens resolved finding when permission revoked again // (5) Re-opens resolved finding when permission revoked again
it('re-opens resolved finding when permission is revoked again', function (): void { it('re-opens resolved finding when permission is revoked again', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();

View File

@ -11,7 +11,7 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue();

View File

@ -11,7 +11,7 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue();

View File

@ -11,7 +11,7 @@
expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue();
expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue();

View File

@ -16,7 +16,7 @@
expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_INVENTORY_SYNC_RUN, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_FINDINGS_TRIAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse();
expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse();

View File

@ -24,10 +24,10 @@
->and($spec->color)->toBe('warning'); ->and($spec->color)->toBe('warning');
}); });
it('renders unknown for removed acknowledged status badges', function (): void { it('still renders acknowledged status badge', function (): void {
$spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); $spec = BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED);
expect($spec->label)->toBe('Unknown') expect($spec->label)->toBe('Triaged')
->and($spec->color)->toBe('gray'); ->and($spec->color)->toBe('gray');
}); });

View File

@ -6,7 +6,6 @@
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
use App\Services\Directory\RoleDefinitionsSyncService; use App\Services\Directory\RoleDefinitionsSyncService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
@ -37,7 +36,7 @@
$run = $result->run; $run = $result->run;
expect($run->type)->toBe(OperationRunType::DirectoryRoleDefinitionsSync->value); expect($run->type)->toBe('directory_role_definitions.sync');
expect($run->context['provider_connection_id'] ?? null)->toBeInt(); expect($run->context['provider_connection_id'] ?? null)->toBeInt();
$url = OperationRunLinks::tenantlessView($run); $url = OperationRunLinks::tenantlessView($run);

View File

@ -2,8 +2,6 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\Finding;
it('passes shared canonical control references through tenant review composition', function (): void { it('passes shared canonical control references through tenant review composition', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 1); $snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 1);
@ -16,30 +14,5 @@
->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance') ->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1) ->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1)
->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance') ->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance')
->and($openRisks->summary_payload['canonical_controls'][0]['control_key'] ?? null)->toBe('endpoint_hardening_compliance'); ->and($openRisks->summary_payload['canonical_controls'])->toBe([]);
});
it('excludes removed acknowledged findings from open risk highlights', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => 'acknowledged',
'subject_external_id' => 'legacy-acknowledged',
]);
$triagedFinding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => 'canonical-triaged',
]);
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 0);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$openRisks = $review->sections->firstWhere('section_key', 'open_risks');
$entries = $openRisks->render_payload['entries'] ?? [];
expect($entries)->toHaveCount(1)
->and($entries[0]['id'] ?? null)->toBe((int) $triagedFinding->getKey())
->and(collect($entries)->pluck('status')->all())->not->toContain('acknowledged');
}); });

View File

@ -23,7 +23,7 @@
->assertSee('Tenant Panel Entry') ->assertSee('Tenant Panel Entry')
->assertSee('Switch tenant') ->assertSee('Switch tenant')
->assertSee('Clear tenant scope') ->assertSee('Clear tenant scope')
->assertDontSee(__('localization.shell.search_tenants')) ->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant'); ->assertDontSee('admin/select-tenant');
}); });

View File

@ -33,7 +33,7 @@
expect($triaged->color)->toBe('gray'); expect($triaged->color)->toBe('gray');
$legacyAcknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged'); $legacyAcknowledged = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'acknowledged');
expect($legacyAcknowledged->label)->toBe('Unknown'); expect($legacyAcknowledged->label)->toBe('Triaged');
expect($legacyAcknowledged->color)->toBe('gray'); expect($legacyAcknowledged->color)->toBe('gray');
$inProgress = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'in_progress'); $inProgress = BadgeCatalog::spec(BadgeDomain::FindingStatus, 'in_progress');

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
it('exposes only canonical open statuses for findings', function (): void {
expect(Finding::openStatuses())->toBe([
Finding::STATUS_NEW,
Finding::STATUS_TRIAGED,
Finding::STATUS_IN_PROGRESS,
Finding::STATUS_REOPENED,
]);
expect(Finding::openStatusesForQuery())->toBe(Finding::openStatuses());
});
it('does not treat acknowledged as a canonical open or terminal status', function (): void {
expect(Finding::canonicalizeStatus('acknowledged'))->toBe('acknowledged');
expect(Finding::isOpenStatus('acknowledged'))->toBeFalse();
expect(Finding::isTerminalStatus('acknowledged'))->toBeFalse();
});

View File

@ -9,14 +9,15 @@
$types = app(OperationLifecyclePolicy::class)->coveredTypeNames(); $types = app(OperationLifecyclePolicy::class)->coveredTypeNames();
expect($types)->toBe([ expect($types)->toBe([
'baseline.capture', 'baseline_capture',
'baseline.compare', 'baseline_compare',
'inventory.sync', 'inventory_sync',
'policy.sync', 'policy.sync',
'directory.groups.sync', 'policy.sync_one',
'directory.role_definitions.sync', 'entra_group_sync',
'directory_role_definitions.sync',
'backup_set.update', 'backup_set.update',
'backup.schedule.execute', 'backup_schedule_run',
'restore.execute', 'restore.execute',
'tenant.review_pack.generate', 'tenant.review_pack.generate',
'tenant.review.compose', 'tenant.review.compose',
@ -27,19 +28,19 @@
it('requires direct failed-job bridges for lifecycle policy entries that declare them', function (): void { it('requires direct failed-job bridges for lifecycle policy entries that declare them', function (): void {
$validator = app(OperationLifecyclePolicyValidator::class); $validator = app(OperationLifecyclePolicyValidator::class);
expect($validator->jobUsesDirectFailedBridge('baseline.capture'))->toBeTrue() expect($validator->jobUsesDirectFailedBridge('baseline_capture'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('baseline.compare'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('baseline_compare'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('inventory.sync'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('inventory_sync'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('policy.sync'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('policy.sync'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('tenant.review.compose'))->toBeTrue() ->and($validator->jobUsesDirectFailedBridge('tenant.review.compose'))->toBeTrue()
->and($validator->jobUsesDirectFailedBridge('backup.schedule.execute'))->toBeFalse(); ->and($validator->jobUsesDirectFailedBridge('backup_schedule_run'))->toBeFalse();
}); });
it('requires explicit timeout and fail-on-timeout declarations for covered jobs', function (): void { it('requires explicit timeout and fail-on-timeout declarations for covered jobs', function (): void {
$validator = app(OperationLifecyclePolicyValidator::class); $validator = app(OperationLifecyclePolicyValidator::class);
expect($validator->jobTimeoutSeconds('baseline.capture'))->toBe(300) expect($validator->jobTimeoutSeconds('baseline_capture'))->toBe(300)
->and($validator->jobFailsOnTimeout('baseline.capture'))->toBeTrue() ->and($validator->jobFailsOnTimeout('baseline_capture'))->toBeTrue()
->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240) ->and($validator->jobTimeoutSeconds('backup_set.update'))->toBe(240)
->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue() ->and($validator->jobFailsOnTimeout('backup_set.update'))->toBeTrue()
->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420) ->and($validator->jobTimeoutSeconds('restore.execute'))->toBe(420)

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Finding;
use App\Support\Filament\FilterOptionCatalog;
it('exposes only canonical finding statuses in the shared filter catalog', function (): void {
expect(FilterOptionCatalog::findingStatuses())->toBe([
Finding::STATUS_NEW => 'New',
Finding::STATUS_TRIAGED => 'Triaged',
Finding::STATUS_IN_PROGRESS => 'In progress',
Finding::STATUS_REOPENED => 'Reopened',
Finding::STATUS_RESOLVED => 'Resolved',
Finding::STATUS_CLOSED => 'Closed',
Finding::STATUS_RISK_ACCEPTED => 'Risk accepted',
]);
});
it('does not offer acknowledged as a legacy findings filter option', function (): void {
expect(FilterOptionCatalog::findingStatuses())->not->toHaveKey('acknowledged');
});

View File

@ -15,7 +15,6 @@
use App\Support\Navigation\CanonicalNavigationContext; use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken; use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint; use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -65,7 +64,6 @@
OperationRun::factory() OperationRun::factory()
->forTenant($bravoTenant) ->forTenant($bravoTenant)
->create([ ->create([
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Queued->value, 'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value, 'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(6), 'created_at' => now()->subMinutes(6),

View File

@ -53,29 +53,6 @@
]); ]);
}); });
it('records a tenant-owned usage event for an archived tenant', function () {
$workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->archived()->create();
$user = User::factory()->create();
$event = app(ProductTelemetryRecorder::class)->record(
eventName: ProductUsageEventCatalog::ONBOARDING_CHECKPOINT_COMPLETED,
workspaceId: (int) $workspace->getKey(),
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
subjectType: 'tenant_onboarding_session',
subjectId: 99,
metadata: [
'checkpoint_key' => 'verify_access',
'lifecycle_state' => 'draft',
],
);
expect($event->workspace_id)->toBe((int) $workspace->getKey())
->and($event->tenant_id)->toBe((int) $tenant->getKey())
->and($event->feature_area)->toBe('onboarding');
});
it('rejects unknown event names before writing telemetry rows', function () { it('rejects unknown event names before writing telemetry rows', function () {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->for($workspace)->create(); $tenant = Tenant::factory()->for($workspace)->create();

View File

@ -15,7 +15,7 @@ ## Purpose
## Current Product Position ## Current Product Position
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls und inzwischen repo-real umgesetzten Customer-safe Review Consumption, Risk-Acceptance/Exception-Workflow, Findings-/Governance-Inboxen und einer DE/EN-Locale-Foundation. Die Repo-Wahrheit liegt damit klar ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Portfolio- und Commercial-Plattform ausgereift: Cross-Tenant-Workflows, Compare/Promotion, Billing-/Lifecycle-Reife und Private-AI-Governance bleiben unvollstaendig. Zusaetzlich zeigt der Repo-Stand weiterhin eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden.
## Status Model ## Status Model
@ -41,24 +41,24 @@ ## Roadmap Coverage Summary
| Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes | | Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes |
|---|---|---:|---|---|---|---| |---|---|---:|---|---|---|---|
| R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. | | R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. |
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | yes | Reviews, Evidence, Review Packs, Customer Review Workspace und Control-/Exception-Layer greifen als reale Governance-Surface zusammen. | | R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. |
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. | | Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. | | Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
| UI & Product Maturity Polish | implemented_partial | strong | partial | partial repo tests, not run | no | Empty States, Navigation, Localization und read-only Review-Polish sind real, aber kein geschlossenes Theme-Completion-Signal. | | UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. |
| Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. | | Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. |
| Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. | | Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. |
| R1.9 Platform Localization v1 | implemented_verified | strong | yes | repo tests, not run | foundation-only | Locale-Resolver, Override/Praeferenz, Workspace-Default, Fallback und lokalisierte Notifications sind repo-real. | | R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. |
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
| R2 Completion: customer review, support, help | adopted | strong | yes | repo tests, not run | yes | Customer Review Workspace, Support Diagnostics/Requests und Help-Katalog sind repo-real. | | R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
| Findings Workflow v2 / Execution Layer | adopted | strong | yes | repo tests, not run | almost | Triage, Ownership, My Work, Intake, Governance Inbox, Exceptions und Alerts/Hygiene sind real; Cross-Tenant-Decisioning bleibt spaeter. | | Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. |
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. | | Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. | | MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow jenseits des jetzigen Exception-/Review-Layers. | | Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. |
| Drift & Change Governance | implemented_partial | strong | yes | repo tests, not run | almost | Drift review, accepted-risk governance, exception validity und Governance-Inbox-Surfaces sind repo-real; portfolio-weite Eskalation bleibt offen. | | Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. |
| Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. | | Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. |
| PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. | | PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. |
@ -69,13 +69,10 @@ ## Implemented Capabilities
| OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` | | OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` |
| Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` | | Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` |
| Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` | | Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` |
| Findings inboxes and governance inbox | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Filament/Pages/Findings/MyFindingsInbox.php`; `app/Filament/Pages/Findings/FindingsIntakeQueue.php`; `app/Filament/Pages/Governance/GovernanceInbox.php`; `tests/Feature/Findings/MyWorkInboxTest.php`; `tests/Feature/Governance/*` |
| Finding exceptions and risk acceptance workflow | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/FindingException.php`; `app/Services/Findings/FindingExceptionService.php`; `app/Filament/Resources/FindingExceptionResource.php`; `tests/Feature/Findings/FindingExceptionWorkflowTest.php` |
| Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` | | Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` |
| Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` | | Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` |
| Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` | | Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` |
| Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` | | Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` |
| Customer review workspace | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`; `tests/Feature/Reviews/*`; `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` |
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` | | Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` | | Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` | | Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
@ -84,7 +81,6 @@ ## Implemented Capabilities
| Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` | | Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` |
| In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` | | In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` |
| Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` | | Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` |
| Localization foundation | implemented_verified | yes | yes | repo tests, not run | partial | foundation-only | `app/Services/Localization/LocaleResolver.php`; `app/Http/Controllers/LocalizationController.php`; `tests/Feature/Localization/*` |
| Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` | | Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` |
| Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` | | Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` |
| Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` | | Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` |
@ -103,15 +99,14 @@ ## Foundation-Only Capabilities
- Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews. - Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews.
- Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen. - Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen.
- Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports. - Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports.
- Localization foundation: resolved locale precedence, Workspace-Default, User-Praeferenz/Override und Notification-Formatting sind real, aber Enablement statt eigener Produkt-Surface.
- Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig. - Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig.
- Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche. - Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche.
- Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt. - Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt.
## Partial Capabilities ## Partial Capabilities
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots, Review Packs und der Customer Review Workspace sind repo-real, aber portfolio-weite Consumption- und Sharing-Patterns bleiben offen. - Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
- Findings Workflow v2: Triage, Assignment, My Work, Intake, Governance Inbox, Exceptions und Notifications sind vorhanden; spaetere Cross-Tenant-Decisioning-Layer und Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten bleiben offen. - Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten.
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch. - Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
@ -119,12 +114,13 @@ ## Partial Capabilities
## Planned But Not Implemented ## Planned But Not Implemented
- Platform Localization v1
- Private AI Execution & Usage Governance Foundation - Private AI Execution & Usage Governance Foundation
- Human-in-the-Loop Autonomous Governance - Human-in-the-Loop Autonomous Governance
- Standardization & Policy Quality / Intune Linting - Standardization & Policy Quality / Intune Linting
- PSA / Ticketing Handoff - PSA / Ticketing Handoff
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1 - Cross-Tenant Compare and Promotion v1
- Policy Lifecycle / Ghost Policies
- Later compliance overlays beyond the current control/evidence foundation - Later compliance overlays beyond the current control/evidence foundation
## Release Readiness ## Release Readiness
@ -132,8 +128,8 @@ ## Release Readiness
| Release / Theme | Readiness | Notes | | Release / Theme | Readiness | Notes |
|---|---|---| |---|---|---|
| R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. | | R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. |
| R2 Tenant Reviews & Evidence Packs | implemented | Reviews, Evidence Snapshots, Review Packs, Customer Review Workspace und Exception-/Accepted-Risk-Workflow sind repo-real; breitere Commercial-Polish-Themen bleiben separat. | | R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. |
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage und Governance-Surfaces sind da, aber Compare/Promotion und portfolio-weite Action-Layer fehlen. | | R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. |
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. | | Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
## Commercial Readiness ## Commercial Readiness
@ -142,16 +138,14 @@ ### Demo-ready
- Baseline compare and drift walkthroughs - Baseline compare and drift walkthroughs
- Review pack generation and export - Review pack generation and export
- Customer-safe review workspace walkthroughs
- Provider health, onboarding readiness and required permissions - Provider health, onboarding readiness and required permissions
- Support diagnostics - Support diagnostics
- Permission posture and Entra admin roles reporting - Permission posture and Entra admin roles reporting
### Almost sellable ### Almost sellable
- Review-driven governance workflow rund um Tenant Reviews, Customer Review Workspace, accepted risks und Review Packs - Review-driven governance workflow around tenant reviews and review packs
- Baseline drift and restore governance - Baseline drift and restore governance
- Findings workflow mit persönlicher Inbox, Intake, Governance Inbox und Exception-Handling
- Alerting and run visibility for governance operations - Alerting and run visibility for governance operations
- Support requests with contextual diagnostics - Support requests with contextual diagnostics
- Provider readiness and permission posture reporting - Provider readiness and permission posture reporting
@ -165,7 +159,6 @@ ### Foundation-only
- Canonical control catalog - Canonical control catalog
- Stored reports substrate - Stored reports substrate
- Evidence snapshot substrate - Evidence snapshot substrate
- Localization foundation
- Product telemetry - Product telemetry
- Customer health scoring - Customer health scoring
- Operational controls - Operational controls
@ -173,7 +166,9 @@ ### Foundation-only
### Not sellable yet ### Not sellable yet
- Customer Review Workspace v1
- Cross-Tenant Compare and Promotion v1 - Cross-Tenant Compare and Promotion v1
- Localization v1
- Private AI Execution Governance Foundation - Private AI Execution Governance Foundation
- External Support Desk / PSA Handoff - External Support Desk / PSA Handoff
- Compliance Light product layer - Compliance Light product layer
@ -182,39 +177,40 @@ ## Open Gaps & Blockers
| Gap | Type | Impact | Roadmap Area | Recommended Spec | | Gap | Type | Impact | Roadmap Area | Recommended Spec |
|---|---|---|---|---| |---|---|---|---|---|
| Decisioning still spans multiple repo-real inboxes | UX blocker | My Findings, Intake, Governance Inbox und Exception Queue sind real, aber Operators springen weiter zwischen mehreren Spezial-Surfaces und es gibt noch keinen portfolio-weiten Action-Layer | Findings Workflow / MSP Portfolio | P1 Governance Decision Surface Convergence | | Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces | | Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces |
| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility | | Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility |
| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants | | Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants |
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
| Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff | | Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff |
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation | | AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation |
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs still lag neuere Review-, Findings- und Localization-Surfaces | Product planning / roadmap maintenance | none - docs alignment | | Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment |
| Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites | | Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites |
## Recommended Next Specs ## Recommended Next Specs
- `P1 Governance Decision Surface Convergence`: verbindet My Findings, Intake, Governance Inbox, Customer Review Workspace und Exception Queue zu weniger Operator-Journeys und bereitet die Portfolio-Ebene vor. - `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks. - `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks.
- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases. - `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases.
- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later. - `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later.
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.
- `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence. - `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence.
- `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it. - `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it.
## Roadmap Drift Notes ## Roadmap Drift Notes
- `roadmap.md` understates current R2 completion. Customer Review Workspace, published review handoff, review-pack downloads und der Finding-Exception-/Risk-Acceptance-Workflow sind bereits repo-real.
- `roadmap.md` understates findings workflow maturity. My Findings, Intake, Governance Inbox und Exception Queue existieren bereits im Repo.
- `roadmap.md` understates localization maturity. Locale resolution order, Workspace-Default, User-Praeferenz, lokalisierte Notifications und Fallback-Tests sind implementiert.
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas. - `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
- `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo. - `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo.
- `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel. - `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel.
- `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not. - `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not.
- The roadmap is now better at describing still-missing portfolio- und commercial-Layer than the current state of review/findings/localization implementation. Cross-Tenant Compare and Promotion, full billing-state maturity, external PSA handoff and AI Governance still look genuinely unimplemented. - The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented.
- The main drift pattern is underestimation, not overestimation. Customer-facing review consumption is no longer the clearest missing piece; portfolio action and commercial lifecycle are. - The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace.
## Evidence Sources ## Evidence Sources
@ -231,19 +227,12 @@ ## Evidence Sources
- `apps/platform/app/Filament/Pages/TenantDashboard.php` - `apps/platform/app/Filament/Pages/TenantDashboard.php`
- `apps/platform/app/Filament/System/Pages/Dashboard.php` - `apps/platform/app/Filament/System/Pages/Dashboard.php`
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` - `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
- `apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php`
- `apps/platform/app/Filament/Pages/Monitoring/FindingExceptionsQueue.php`
Wichtige Models: Wichtige Models:
- `apps/platform/app/Models/OperationRun.php` - `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Models/Finding.php` - `apps/platform/app/Models/Finding.php`
- `apps/platform/app/Models/FindingException.php` - `apps/platform/app/Models/FindingException.php`
- `apps/platform/app/Models/FindingExceptionDecision.php`
- `apps/platform/app/Models/FindingExceptionEvidenceReference.php`
- `apps/platform/app/Models/BaselineProfile.php` - `apps/platform/app/Models/BaselineProfile.php`
- `apps/platform/app/Models/BaselineSnapshot.php` - `apps/platform/app/Models/BaselineSnapshot.php`
- `apps/platform/app/Models/EvidenceSnapshot.php` - `apps/platform/app/Models/EvidenceSnapshot.php`
@ -262,7 +251,6 @@ ## Evidence Sources
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` - `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
- `apps/platform/app/Services/Baselines/BaselineCompareService.php` - `apps/platform/app/Services/Baselines/BaselineCompareService.php`
- `apps/platform/app/Services/Alerts/AlertDispatchService.php` - `apps/platform/app/Services/Alerts/AlertDispatchService.php`
- `apps/platform/app/Services/Findings/FindingExceptionService.php`
- `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php` - `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php`
- `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php` - `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
- `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php` - `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
@ -270,7 +258,6 @@ ## Evidence Sources
- `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` - `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` - `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
- `apps/platform/app/Services/Auth/CapabilityResolver.php` - `apps/platform/app/Services/Auth/CapabilityResolver.php`
- `apps/platform/app/Services/Localization/LocaleResolver.php`
Wichtige Test-Anker im Repo: Wichtige Test-Anker im Repo:
@ -289,4 +276,4 @@ ## Evidence Sources
## Last Updated ## Last Updated
2026-04-29 on branch `platform-dev` 2026-04-27 on branch `248-private-ai-policy-foundation`

View File

@ -1,48 +0,0 @@
# Specification Quality Checklist: Remove Legacy Acknowledged Finding Status Compatibility
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-29
**Feature**: specs/254-remove-acknowledged-compat/spec.md
## Content Quality
- [x] No language/framework/API design leakage; concrete repo surfaces, status constants, capability keys, and shared helpers are named only because this cleanup removes those exact repo-visible compatibility seams.
- [x] Focused on user value and business needs
- [x] Written primarily for product and review stakeholders, with bounded repo-specific terminology only where the cleanup target would otherwise stay ambiguous
- [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 stay outcome-oriented, with bounded repo-specific seams named only where they are required to define the cleanup target
- [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 defines measurable outcomes in Success Criteria and maps them to explicit proof tasks for implementation-time validation
- [x] No unintended implementation design leakage remains beyond the explicit cleanup special-case for named repo-visible compatibility seams
## Test Governance Review
- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, plus bounded `heavy-governance` guard coverage so shared status-badge and filter drift cannot silently reintroduce acknowledged semantics.
- [x] No new browser or heavy-governance family is introduced; retained guard coverage stays explicit and limited to shared findings status seams.
- [x] Suite-cost outcome is net-neutral to slightly negative: acknowledged-only compatibility expectations should be consolidated or deleted rather than widening shared defaults.
## Review Outcome
- [x] Review outcome class: `acceptable-special-case`
- [x] Workflow outcome: `keep`
- [x] Review-note location is explicit: the guardrail and lane-fit notes live in `spec.md`, the checklist, and the final preparation report.
## Notes
- The spec intentionally names concrete findings status constants, capability aliases, shared catalogs, and summary builders because the product value of this slice is removing those exact compatibility seams from repo truth.
- Verification-check acknowledgement and onboarding acknowledgement remain explicit non-goals so the cleanup cannot expand into a broader terminology rewrite.
- Validation pass complete: no clarification markers remain, the slice stays LEAN-001-compliant, and tenant-owned findings continue to treat `workspace_id` plus `tenant_id` as required anchors.

View File

@ -1,121 +0,0 @@
version: 1
kind: findings-acknowledged-compat-removal
scope:
goal: remove productive acknowledged compatibility from findings workflow truth only
non_goals:
- findings lifecycle backfill runtime-surface removal
- creation-time finding invariant hardening
- broader findings lifecycle redesign
- verification acknowledgement cleanup
- onboarding acknowledgement cleanup
- restore impact acknowledgement cleanup
- migration or fallback-reader preservation
canonical_status_contract:
active_open:
- new
- triaged
- in_progress
- reopened
terminal:
- resolved
- closed
- risk_accepted
removed_active_status:
- acknowledged
shared_seams:
model_and_workflow:
owner_files:
- apps/platform/app/Models/Finding.php
- apps/platform/app/Services/Findings/FindingWorkflowService.php
- apps/platform/app/Policies/FindingPolicy.php
requirements:
- no productive findings workflow helper writes or expects acknowledged
- open-status query helpers collapse onto the canonical active-open set only
badge_and_filter_catalogs:
owner_files:
- apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php
- apps/platform/app/Support/Filament/FilterOptionCatalog.php
requirements:
- no badge label exposes acknowledged or legacy acknowledged
- no findings filter offers acknowledged as a current workflow state
capabilities_and_roles:
owner_files:
- apps/platform/app/Support/Auth/Capabilities.php
- apps/platform/app/Services/Auth/RoleCapabilityMap.php
requirements:
- tenant_findings.acknowledge is removed
- surviving findings capability language stays canonical and tenant-scoped
tenant_findings_surfaces:
routes:
- /admin/t/{tenant}/findings
- /admin/t/{tenant}/findings/{record}
owner_files:
- apps/platform/app/Filament/Resources/FindingResource.php
- apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php
- apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
requirements:
- no visible findings workflow affordance presents acknowledged as current work
findings_derived_consumers:
owner_files:
- apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php
- apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php
- apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php
- apps/platform/app/Support/Baselines/BaselineCompareStats.php
- apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php
- apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php
- apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php
- apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
- apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
- apps/platform/app/Services/Baselines/BaselineAutoCloseService.php
- apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php
requirements:
- counts, previews, review disclosures, diagnostics, and alerts use the same canonical open-status set as findings surfaces
- no productive findings-derived consumer treats acknowledged as current work
retained_behavior:
findings_workflow_actions:
- triage
- start_progress
- assign
- resolve
- close
- reopen
- request_exception
- risk_accept
guarantees:
- existing findings lifecycle outcomes remain otherwise unchanged
- no new workflow state or replacement compatibility path is introduced
non_finding_domains:
untouched:
- verification check acknowledgement
- onboarding verification acknowledgement
- restore impact acknowledgement
legacy_data_posture:
findings_table:
- acknowledged columns may remain in schema for now without preserving active runtime semantics
migrations:
- no new migration or persisted compatibility artifact is allowed in this slice
validation_expectations:
no_new_persistence:
- no file under apps/platform/database/migrations may change
- no alias table, persisted mapping, or fallback reader may be introduced
absence_proof:
- no productive findings surface exposes acknowledged as current workflow status
- no productive findings-derived consumer exposes acknowledged as current work
- no findings capability alias remains for acknowledge semantics
regression_proof:
- canonical findings workflow actions still behave unchanged
- non-finding acknowledgement domains remain untouched
lane_classification:
required:
- fast-feedback
- confidence
- heavy-governance
excluded:
- browser

View File

@ -1,103 +0,0 @@
# Data Model — Remove Legacy Acknowledged Finding Status Compatibility
**Spec**: [spec.md](spec.md)
This feature is subtractive. It introduces no new persisted truth and no migration. The data-model impact is the removal of one legacy findings workflow branch from productive code and the reaffirmation of the canonical findings lifecycle as the only active status contract.
## Existing Canonical Entities Reused
### Finding (`findings`)
**Purpose**: Tenant-owned findings workflow truth.
**Key fields (existing)**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `triaged_at`
- `in_progress_at`
- `reopened_at`
- `resolved_at`
- `closed_at`
- `risk_accepted_at` via related exception state where applicable
- `first_seen_at`
- `last_seen_at`
- `times_seen`
- `sla_days`
- `due_at`
- `acknowledged_at`
- `acknowledged_by_user_id`
**Feature use**:
- Remains the single canonical workflow truth for findings.
- Continues to require both `workspace_id` and `tenant_id` as ownership anchors.
- Keeps the surviving active status contract: `new`, `triaged`, `in_progress`, `reopened`.
- Keeps the surviving terminal status contract: `resolved`, `closed`, `risk_accepted`.
- `acknowledged_at` and `acknowledged_by_user_id` may remain in schema for now, but they no longer justify an active workflow status, query branch, or UI affordance.
### FindingException (`finding_exceptions`)
**Purpose**: Existing risk-acceptance and exception truth attached to findings.
**Feature use**:
- Remains unchanged.
- Exists only for regression protection so removing `acknowledged` does not collapse or rename risk-governance semantics.
## Removed Active Workflow Contract
### LegacyAcknowledgedFindingStatus (removed, non-persisted contract)
**Previous role**:
- active status constant on `Finding`
- extra member of `openStatusesForQuery()`
- special-case filter and badge label
- capability alias and RBAC wording branch
- compatibility expectation in findings-facing tests and summary consumers
**Removal rule**:
- no productive code path writes `acknowledged` as current findings status
- no productive code path queries `acknowledged` as part of the active open-status set
- no productive findings UI or summary consumer presents `acknowledged` as current work
- no role or capability mapping preserves `tenant_findings.acknowledge`
## Derived Non-Persisted Contracts
### CanonicalFindingOpenStatusSet (derived)
**Members**:
- `new`
- `triaged`
- `in_progress`
- `reopened`
**Consumers**:
- findings resource and inbox queries
- workspace overview and governance inbox summaries
- review/report disclosure helpers that describe current open findings work
- support-diagnostic bundles that group active findings issues
- alerts, hygiene services, and findings generators that still look up active/open findings
### CanonicalFindingWorkflowPermissionSet (derived)
**Purpose**: Surviving capability vocabulary for findings workflow actions.
**Feature use**:
- remove `tenant_findings.acknowledge`
- keep surviving findings permissions and policy checks authoritative
- keep `404` versus `403` semantics unchanged for tenant-scoped findings surfaces
## Data Ownership Notes
- No new table, column, persisted alias, cache, or compatibility projection is introduced.
- No migration or historical data rewrite is planned.
- Review/report and support-diagnostic consumers remain derived over tenant-owned findings truth; they do not become separate persisted status stores.
- Verification-check acknowledgement, onboarding acknowledgement, and restore acknowledgement remain separate domains and are not remodeled here.
## Removal Invariants
- No productive code path may treat `acknowledged` as a current findings workflow status.
- No productive query helper may include `acknowledged` in the active open findings set.
- No shared badge, filter, summary, review/report disclosure, or support-diagnostic grouping may present `acknowledged` as current findings work.
- No new migration or persisted compatibility artifact may be introduced to preserve the removed branch.
- No non-finding acknowledgement domain may change as collateral damage from this cleanup.

View File

@ -1,266 +0,0 @@
# Implementation Plan: Remove Legacy Acknowledged Finding Status Compatibility
**Branch**: `254-remove-acknowledged-compat` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/254-remove-acknowledged-compat/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/254-remove-acknowledged-compat/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
- Remove productive `acknowledged` compatibility from the findings domain by collapsing canonical status, query, badge, filter, capability, policy, and workflow seams onto the surviving findings lifecycle only.
- Keep the slice subtractive and repo-based: no new state, no migration shim, no repair tooling, no broader lifecycle redesign, and no changes to verification-check or onboarding acknowledgement domains.
- Validate the cleanup through focused workflow, summary, badge or filter, capability, and guard coverage so shared findings-derived counts and operator surfaces converge on one canonical language at the same time.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing findings workflow services, shared badge and filter catalogs, capability registry, and canonical summary builders
**Storage**: PostgreSQL existing `findings`, `finding_exceptions`, `audit_logs`, `operation_runs`, and related read models only; no new persistence or migration is planned
**Testing**: Pest unit, feature, and bounded heavy-governance guard coverage
**Validation Lanes**: fast-feedback, confidence, heavy-governance
**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and canonical `/admin` summary or inbox surfaces
**Project Type**: web
**Performance Goals**: shared query helpers, inboxes, and summary builders keep their current bounded DB-only render profile; the slice should reduce branching and suite noise rather than add overhead
**Constraints**: LEAN-001 replacement over shims; no schema drop by default; preserve current `404` versus `403` isolation semantics; no panel or provider changes; no new assets; no widening into creation-time invariant hardening, backfill-runtime-surface work, or external support handoff
**Scale/Scope**: 1 cleanup slice touching the `Finding` model and factory, findings workflow service and policy seam, shared badge and filter catalog paths, findings resource and inbox surfaces, canonical summary builders, capability and role maps, and the related findings and guard tests
## Likely Affected Repo Surfaces
- Canonical findings status and workflow seams: `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Policies/FindingPolicy.php`, and `apps/platform/database/factories/FindingFactory.php`
- Shared operator vocabulary seams: `apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Support/Auth/Capabilities.php`, and `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
- Tenant findings Filament surfaces: `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, and related workflow concerns
- Findings-derived summary and review helpers still relying on shared open-status handling or explicit `acknowledged` strings: `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`
- Query and generator consumers of `Finding::openStatusesForQuery()`: `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
- Current proof surface likely requiring update or replacement: `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
## Domain / Model Fit
- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, enum family, compatibility table, or derived persistence layer is introduced.
- The canonical active lifecycle stays `new`, `triaged`, `in_progress`, and `reopened`, with `resolved`, `closed`, and `risk_accepted` remaining the canonical terminal set. `acknowledged` is removed as an active status contract rather than remapped through runtime helpers.
- `openStatusesForQuery()` and related status helpers should collapse onto the canonical open-status set instead of preserving an extra compatibility branch for `acknowledged`.
- Existing `acknowledged_at` and `acknowledged_by_user_id` columns are not justification for compatibility behavior in this slice. Default plan posture is to leave schema shape unchanged and remove productive semantics only.
- Legacy factory or fixture helpers that create `acknowledged` findings should be deleted or confined to explicitly documented stale-data edge proof only if implementation later proves that is still needed. They should not remain the default way to express current workflow truth.
## UI / Filament & Livewire Fit
- All touched operator surfaces remain native Filament v5 on Livewire v4. No custom dashboard framework, no panel change, and no new provider registration work are needed.
- `FindingResource` already has a `view` page, so the feature does not create a Filament global-search compliance problem. No new searchable resource is introduced.
- Shared badge rendering stays on `BadgeCatalog` plus `BadgeRenderer`, and shared filter vocabulary stays on `FilterOptionCatalog`; the cleanup must remove `acknowledged` from those shared paths rather than introducing page-local label overrides.
- Findings table, detail, and inbox surfaces should remove `acknowledged` from triage or progress visibility checks, filter options, summary counts, and helper wording together so operator UI does not drift between list, detail, and summary shells.
- No new destructive action is added. Existing destructive-like finding actions remain out of scope except that any touched action surface must preserve current `->requiresConfirmation()` and server-side capability or policy enforcement.
- No panel-only or shared asset changes are planned, so deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets.
## RBAC / Policy Fit
- Tenant membership and workspace membership remain the isolation boundaries: non-members stay `404`, entitled members missing the surviving capability stay `403`, and no resource existence leak is introduced while cleaning status language.
- `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE` is removed instead of preserved as an alias. `RoleCapabilityMap`, `FindingPolicy`, and `FindingWorkflowService` should converge on the surviving findings capabilities only.
- The feature does not add a new role, a new authorization plane, or any page-local permission dialect. It narrows existing capability vocabulary.
- Disabled helper text and action affordances should continue to rely on the existing shared UI enforcement path while referencing only canonical findings workflow permissions.
## Audit / Logging Fit
- No new `AuditActionId` is introduced.
- Existing findings workflow audit verbs such as triage, assign, start progress, resolve, close, reopen, and risk acceptance remain canonical. The cleanup should not revive or add a finding-acknowledged audit dialect.
- Historical pre-production audit or metadata values do not justify runtime label shims. Verification-check acknowledgement audit behavior remains untouched.
## Migration / Data Shape Fit
- No new migration, no historical data backfill, and no fallback reader are planned.
- Repo evidence shows the findings status is a string column and the `acknowledged` behavior lives in code, factories, and tests rather than in a database enum or required migration path.
- Default implementation posture is to leave `findings` table columns intact for now and remove productive status compatibility from code and fixtures only. If schema removal appears necessary later, that must be split or re-justified instead of silently widening this cleanup.
- Local pre-production rows that still contain `acknowledged` are not a product compatibility requirement. Dev data reset or fixture replacement is preferred over runtime support.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament plus shared badge, filter, and summary primitives
- **Shared-family relevance**: status messaging, findings workflow actions, shared badge semantics, shared filter vocabularies, canonical summary counts and previews
- **State layers in scope**: page, detail, URL-query
- **Audience modes in scope**: operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first / diagnostics-second / support-raw-third
- **Raw/support gating plan**: existing capability-gated diagnostics remain unchanged; no new raw or support surface is introduced
- **One-primary-action / duplicate-truth control**: findings surfaces keep one canonical workflow language so triage and progress remain the dominant next actions without a duplicate `acknowledged` branch competing in badges, filters, or summaries
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory now; any leftover productive `acknowledged` findings seam after implementation is a blocker
- **Special surface test profiles**: standard-native-filament, global-context-shell
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none; the removal must happen in the shared seams themselves rather than through local exemptions
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `Finding`, `FindingWorkflowService`, `FindingPolicy`, `FindingStatusBadge`, `FilterOptionCatalog`, `Capabilities`, `RoleCapabilityMap`, `FindingResource`, `ListFindings`, `MyFindingsInbox`, `WorkspaceHealthSummaryQuery`, `WorkspaceOverviewBuilder`, `GovernanceInboxSectionBuilder`, `BaselineCompareStats`, `TenantReviewSectionFactory`, and the generator or alert consumers of shared open-status helpers
- **Shared abstractions reused**: `BadgeCatalog`, `BadgeRenderer`, `FilterOptionCatalog`, shared findings status helpers, `UiEnforcement`, and the canonical capability registry
- **New abstraction introduced? why?**: none; the correct move is to remove one legacy branch from existing shared seams
- **Why the existing abstraction was sufficient or insufficient**: the shared seams are sufficient once the compatibility branch is removed centrally; they are the reason the cleanup cannot stay local to one page or test file
- **Bounded deviation / spread control**: none; any repo surface still naming productive findings `acknowledged` after the cleanup is drift and should be removed rather than wrapped
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: existing findings and summary surfaces only; no `OperationRun` start or link semantics change
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing findings, review, and governance vocabulary only
- **Neutral platform terms / contracts preserved**: existing platform and findings vocabulary remains; the cleanup narrows a finding-domain alias rather than spreading provider language
- **Retained provider-specific semantics and why**: none
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshots-second: PASS - findings remain the last-observed tenant truth, and no backup or snapshot contract changes are introduced
- Read/write separation: PASS - the slice does not add a new write path; it only narrows status semantics on existing findings workflows
- Graph contract path: PASS - no Microsoft Graph contract or provider endpoint change is involved
- Deterministic capabilities: PASS - the capability registry becomes simpler by removing a stale alias instead of expanding capability derivation
- RBAC-UX and isolation: PASS - `/admin/t/{tenant}` findings surfaces remain tenant-scoped; non-members stay `404`; in-scope members missing surviving findings capability stay `403`; no raw capability strings should survive after cleanup
- Workspace isolation / tenant isolation: PASS - tenant-owned findings and derived canonical summaries keep current workspace plus tenant entitlement rules
- Destructive confirmation standard: PASS - no new destructive action is introduced; any touched destructive-like findings action must preserve current confirmation and authorization semantics
- Global search safety: PASS - `FindingResource` already has a view page, and no new searchable resource is added
- OperationRun observability and Ops-UX: PASS - no new operation type, run start surface, or run-notification path is introduced
- Data minimization: PASS - no new payload, no raw evidence expansion, and no new audit family is introduced
- Test governance (`TEST-GOV-001`): PASS - proof stays in focused unit plus feature coverage with one explicit retained heavy-governance guard layer for shared badge or filter drift
- Proportionality (`PROP-001`) and no premature abstraction (`ABSTR-001`): PASS - the plan is subtractive and introduces no new abstraction, registry, or semantic framework
- Persisted truth (`PERSIST-001`): PASS - no new table, entity, artifact, or stored compatibility layer is added
- Behavioral state (`STATE-001`): PASS - the feature removes a legacy active-workflow branch instead of adding a new state family
- UI semantics (`UI-SEM-001`) and shared pattern first (`XCUT-001`): PASS - badge, filter, workflow, and summary semantics stay on existing shared seams rather than a new interpretation layer
- Provider boundary (`PROV-001`) and few layers (`V1-EXP-001`, `LAYER-001`): PASS - no provider seam or extra layer is introduced
- Filament-native UI and planning contract: PASS - Filament v5 remains on Livewire v4, provider registration remains unchanged in `apps/platform/bootstrap/providers.php`, and no panel or asset strategy change is required
- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for findings status helpers, badge or filter catalog semantics, and capability-registry cleanup; Feature for findings workflow actions, resource or inbox behavior, policy outcomes, and shared summary consumers; Heavy-Governance for the explicit guard layer that blocks ad-hoc status badge or table drift
- **Affected validation lanes**: fast-feedback, confidence, heavy-governance
- **Why this lane mix is the narrowest sufficient proof**: the core risk is central semantic drift across shared helpers and operator surfaces, not browser choreography or new async behavior. Focused unit and feature coverage prove the canonical status path, while one retained guard layer ensures `acknowledged` does not reappear through shared UI seams
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
- **Fixture / helper / factory / seed / context cost risks**: acknowledged-specific fixtures and factory states are likely removal targets; keep any remaining stale-data setup explicit and local instead of spreading a legacy default across helper layers
- **Expensive defaults or shared helper growth introduced?**: no; expected net-neutral to negative because the slice removes compatibility branches and stale test setup
- **Heavy-family additions, promotions, or visibility changes**: no new heavy family is planned; one existing shared guard layer remains explicit because badge and filter drift can otherwise reintroduce removed semantics silently
- **Surface-class relief / special coverage rule**: standard-native-filament and global-context-shell relief are sufficient; no browser lane is required for this cleanup
- **Closing validation and reviewer handoff**: rerun the focused commands above, verify that no productive findings seam, capability alias, badge label, filter option, or summary builder still treats `acknowledged` as current workflow truth, and verify that verification or onboarding acknowledgement domains remain unchanged
- **Budget / baseline / trend follow-up**: expected net-neutral to slightly negative because compatibility-only tests should be removed or consolidated
- **Review-stop questions**: did implementation leave `acknowledged` in a shared status helper, a policy or capability path, a badge or filter catalog, a summary builder, or a generator test family; did it widen into schema removal or non-finding acknowledgement work
- **Escalation path**: reject-or-split
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: this slice is a bounded cleanup; creation-time invariant hardening, backfill-runtime-surface removal, and external support handoff already remain explicit separate follow-up work
## Rollout & Risk Controls
- Implement as replacement, not aliasing. Shared helpers, capability registries, and summary builders should converge directly on canonical statuses in the same slice.
- Treat local stale `acknowledged` rows as pre-production cleanup debt, not a customer compatibility contract. Do not add fallback readers or UI labels to preserve them.
- Preserve scope boundaries aggressively: verification acknowledgement, onboarding verification acknowledgement, restore impact acknowledgement, and non-finding support acknowledgement semantics stay untouched.
- Review stop conditions should fire if implementation tries to drop schema, invent a new compatibility mapper, or widen into findings lifecycle redesign.
- Rollout is code-only and repo-local. No queue, deployment, asset, or migration sequencing is expected.
## Project Structure
### Documentation (this feature)
```text
specs/254-remove-acknowledged-compat/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── checklists/
│ └── requirements.md
├── contracts/
│ └── findings-acknowledged-compat-removal.contract.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Findings/
│ │ └── Resources/FindingResource/
│ ├── Jobs/Alerts/
│ ├── Models/
│ ├── Policies/
│ ├── Services/
│ │ ├── Auth/
│ │ ├── Baselines/
│ │ ├── EntraAdminRoles/
│ │ ├── Findings/
│ │ ├── PermissionPosture/
│ │ └── TenantReviews/
│ └── Support/
│ ├── Auth/
│ ├── Badges/
│ ├── CustomerHealth/
│ ├── Filament/
│ ├── GovernanceInbox/
│ └── Workspaces/
├── database/
│ └── factories/
└── tests/
├── Feature/
│ ├── Auth/
│ ├── Findings/
│ ├── Guards/
│ ├── PermissionPosture/
│ └── EntraAdminRoles/
└── Unit/
├── Badges/
└── Findings/
```
**Structure Decision**: Laravel monolith. Implementation should stay inside existing findings model, workflow, policy, shared support, Filament resource or page, factory, and test directories rather than creating a new namespace or migration track.
## Complexity Tracking
No constitution violation is expected. If implementation later proves it needs schema removal, a compatibility shim, or a new translation layer, that is a stop condition and should be split or rejected rather than absorbed here.
## Proportionality Review
N/A - this slice removes a legacy active-workflow alias and a stale capability alias. It introduces no new enum, presenter, persistence, contract layer, or taxonomy.
## Phase 0 — Research (output: `research.md`)
- Confirm the exact productive findings seams that still use `acknowledged` and separate them from explicitly out-of-scope non-finding acknowledgement domains.
- Confirm the no-migration posture for existing `acknowledged_*` fields and local stale rows under LEAN-001.
- Confirm which summary or review helpers still encode literal `acknowledged` status expectations and therefore belong in this cleanup instead of a later lifecycle redesign.
- Confirm which existing tests should be deleted, narrowed, or rewritten rather than preserved as compatibility proof.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
- `data-model.md` should describe the surviving canonical findings status set, the collapsed open-status query contract, the removal of the acknowledge capability alias, and the unchanged schema posture.
- `contracts/findings-acknowledged-compat-removal.contract.yaml` should capture the cleanup matrix across model helpers, workflow and policy authorization, shared badge or filter catalogs, findings resource behavior, summary consumers, and out-of-scope acknowledgement domains.
- `quickstart.md` should document the intended implementation order, validation commands, and review stop conditions for scope drift.
## Phase 1 — Agent Context Update
After Phase 1 artifacts are generated, update Copilot context from the plan:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
- Collapse `Finding` status helpers and legacy model helpers onto the canonical status set only.
- Remove `TENANT_FINDINGS_ACKNOWLEDGE` and update role mappings, workflow authorization, and policy checks to the surviving capability set.
- Remove `acknowledged` from shared badge and filter catalogs and from findings resource or inbox workflow affordances.
- Update shared summary and generator consumers of `openStatusesForQuery()` or explicit `acknowledged` status lists so counts, previews, and auto-close behavior align with canonical statuses.
- Delete or rewrite acknowledged-compatibility tests and factories, then add focused regression proof for the surviving canonical workflow, summary alignment, and guard coverage.
- Verify that no non-finding acknowledgement domains were touched and that no migration or compatibility shim was introduced.
## Constitution Check (Post-Design)
Re-check target: PASS. The post-design shape must stay subtractive, keep Filament v5 on Livewire v4, leave provider registration unchanged in `apps/platform/bootstrap/providers.php`, keep `FindingResource` global-search-safe through its existing view page, add no new destructive action or asset bundle, and preserve the no-migration, no-compatibility-shim posture.

View File

@ -1,36 +0,0 @@
# Quickstart — Remove Legacy Acknowledged Finding Status Compatibility
## Prereqs
- Docker running
- Laravel Sail dependencies installed
- Existing findings, RBAC, summary, and generator test fixtures available
- Existing seeded tenant/workspace context for targeted findings workflow tests
## Run locally
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
- No schema change is expected, but use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction`
- Run targeted tests after implementation:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Alerts/SlaDueAlertTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
- Format after implementation: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Manual smoke after implementation
1. Sign in to `/admin/t/{tenant}/findings` as an entitled tenant operator and confirm the findings register and detail use canonical status badges, filters, helper text, and workflow wording only.
2. Exercise canonical findings actions such as `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Risk accept` and confirm no action or helper text refers to an acknowledge alias.
3. Open the affected canonical `/admin` summary and inbox surfaces and confirm counts and previews match the same canonical open findings set as the findings register.
4. Open an in-scope tenant review, review-pack, or support-diagnostic surface that renders findings-derived open-work disclosure and confirm it does not describe `acknowledged` as current work.
5. Verify capability-driven findings gating no longer references `tenant_findings.acknowledge` while preserving existing `404` versus `403` behavior.
6. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new persisted compatibility artifact was introduced.
## Notes
- Filament v5 remains on Livewire v4.0+ in this repo; the cleanup stays inside existing native Filament resources, pages, and shared support helpers.
- No panel or provider registration changes are planned; `apps/platform/bootstrap/providers.php` remains authoritative if provider work is ever needed later.
- `FindingResource` already has a view page, so the feature does not create a global-search contract issue.
- No asset changes are expected, so there is no additional `filament:assets` deployment work for this slice.
- LEAN-001 applies directly: remove compatibility branches instead of preserving aliases, fallback readers, or migrations for historical pre-production rows.

View File

@ -1,129 +0,0 @@
# Research — Remove Legacy Acknowledged Finding Status Compatibility
**Date**: 2026-04-29
**Spec**: [spec.md](spec.md)
This document records the repo-grounded planning decisions for the acknowledged-compatibility cleanup slice. All decisions assume the current pre-production LEAN-001 posture.
## Decision 1 — Remove acknowledged semantics at shared seams, not through page-local relabeling
**Decision**: Delete productive `acknowledged` compatibility from the shared findings seams that currently define status truth, query truth, badge vocabulary, filter vocabulary, workflow eligibility, and capability language. Do not treat this as a page-local label replacement.
**Rationale**:
- The drift is cross-surface today: `Finding::STATUS_ACKNOWLEDGED`, `Finding::openStatusesForQuery()`, `FilterOptionCatalog`, `BadgeCatalog`, `FindingWorkflowService`, `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE`, and multiple summary consumers all preserve the old vocabulary.
- A list-only or badge-only rename would leave summary counts, disabled helper text, and RBAC wording inconsistent.
- XCUT-001 requires converging on the existing shared path instead of adding local exceptions.
**Evidence**:
- `apps/platform/app/Models/Finding.php`
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- `apps/platform/app/Support/Filament/FilterOptionCatalog.php`
- `apps/platform/app/Support/Badges/Domains/FindingStatusBadge.php`
- `apps/platform/app/Support/Auth/Capabilities.php`
- `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
**Alternatives considered**:
- Relabel `acknowledged` to `triaged` only on findings pages.
- Rejected: shared queries, summaries, and capability guidance would still preserve conflicting truth.
- Keep a read-side compatibility mapper indefinitely.
- Rejected: LEAN-001 forbids preserving a pre-production legacy branch without a current-release need.
## Decision 2 — Treat stale acknowledged rows and columns as pre-production residue, not as a runtime compatibility contract
**Decision**: Keep the cleanup code-only by default. Remove productive semantics first and do not add migration shims, fallback readers, or preserved UI labels just because `acknowledged_at` or `acknowledged_by_user_id` columns still exist locally.
**Rationale**:
- The repo is explicitly pre-production, and LEAN-001 prefers replacement or deletion over historical compatibility behavior.
- The current problem is active workflow semantics in code and tests, not an unavoidable database constraint.
- The narrowest correct implementation is to stop writing, querying, and presenting `acknowledged` as current findings truth.
**Evidence**:
- `apps/platform/app/Models/Finding.php`
- `.specify/memory/constitution.md` (LEAN-001, PERSIST-001)
- `docs/product/spec-candidates.md`
**Alternatives considered**:
- Add a migration or fallback reader now.
- Rejected: widens scope into persistence work not justified by current release truth.
- Preserve `legacy acknowledged` UI labels until later.
- Rejected: keeps the removed semantics productized.
## Decision 3 — Keep findings-derived review, report, and support-diagnostic consumers in scope where they surface current open-work truth
**Decision**: Include review/report and support-diagnostic consumers in this cleanup only where they derive current findings-open counts, disclosure text, or issue grouping from the same shared status helpers as canonical summaries.
**Rationale**:
- Repo truth shows `TenantReviewSectionFactory` and `SupportDiagnosticBundleBuilder` still depend on acknowledged-aware status logic.
- Leaving those consumers out would preserve productive status drift even if the findings register and canonical `/admin` summaries were cleaned.
- This remains bounded because the slice is limited to findings-derived open-work semantics, not broader review-pack, evidence, or diagnostic redesign.
**Evidence**:
- `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`
- `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`
- `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`
- `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`
**Alternatives considered**:
- Defer review/report and diagnostics entirely.
- Rejected: current productive consumers would still present split workflow truth.
- Broaden into review-pack or diagnostic domain redesign.
- Rejected: outside the smallest cleanup slice.
## Decision 4 — Keep non-finding acknowledgement domains explicitly out of scope
**Decision**: Do not rename or remove acknowledgement semantics outside the findings domain, including verification-check acknowledgement, onboarding-verification acknowledgement, and restore impact acknowledgement.
**Rationale**:
- Those domains carry different user intent and do not prove that findings status compatibility must remain.
- Mixing them into this slice would widen terminology cleanup into unrelated workflows.
- The spec already depends on maintaining bounded ownership and avoiding accidental cross-domain churn.
**Evidence**:
- `apps/platform/app/Services/Verification/VerificationCheckAcknowledgementService.php`
- `apps/platform/app/Filament/Support/VerificationReportViewer.php`
- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- `apps/platform/app/Filament/Resources/RestoreRunResource.php`
**Alternatives considered**:
- Normalize every `acknowledge*` term in the repo at once.
- Rejected: too broad and not required for the findings cleanup to be correct.
## Decision 5 — Keep validation in focused Feature, Unit, and retained guard lanes only
**Decision**: Prove the cleanup with focused findings workflow tests, focused summary-consumer tests, focused capability cleanup tests, and the already-retained heavy-governance guard coverage. Do not add browser coverage.
**Rationale**:
- The business risk is shared-seam drift, not browser choreography or async execution.
- The repo already has meaningful findings, generator, summary, and guard test families that can be narrowed or rewritten.
- TEST-GOV-001 prefers the smallest proving lane mix that guards business truth.
**Evidence**:
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`
- `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
**Alternatives considered**:
- Add browser smoke coverage.
- Rejected: low additional value for this cleanup.
- Preserve broad acknowledged-compatibility fixture families.
- Rejected: would keep the removed semantics alive in the suite.
## Decision 6 — Remove the stale findings capability alias instead of translating it forever
**Decision**: Delete `tenant_findings.acknowledge` from the canonical capability registry and role mappings, and converge disabled helper text and authorization expectations on the surviving findings permissions.
**Rationale**:
- The acknowledged alias keeps RBAC language inconsistent with the canonical triage action.
- Capability drift is part of the user-visible problem in this slice, not a separate concern.
- RBAC-UX requires server-side truth to stay on the canonical capability set, not parallel aliases.
**Evidence**:
- `apps/platform/app/Support/Auth/Capabilities.php`
- `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
- `apps/platform/app/Policies/FindingPolicy.php`
**Alternatives considered**:
- Keep the alias as an undocumented backward-compatibility seam.
- Rejected: preserves the exact semantics blocker this feature is intended to remove.

View File

@ -1,283 +0,0 @@
# Feature Specification: Remove Legacy Acknowledged Finding Status Compatibility
**Feature Branch**: `254-remove-acknowledged-compat`
**Created**: 2026-04-29
**Status**: Draft
**Input**: User description: "Prepare the next repo-based cleanup slice that removes legacy acknowledged finding-status compatibility and collapses findings workflow semantics onto canonical triaged or open handling without changing customer-facing workflow scope or reintroducing repair tooling."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot still carries two parallel findings workflow languages. The product treats `triaged` as the canonical operator meaning, but productive code, shared queries, status filters, badges, role mappings, and workflow tests still preserve `acknowledged` compatibility as if it were an active workflow truth.
- **Today's failure**: Operators and maintainers can still encounter `acknowledged` as a current finding status through shared helpers, filter options, badge labels, capability aliases, and findings-derived summary logic. That weakens workflow clarity, keeps RBAC language inconsistent, and makes shared counts and previews harder to trust.
- **User-visible improvement**: Tenant and workspace operators see one canonical findings workflow language. Status badges, filters, summary counts, helper text, and workflow actions consistently speak in `new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, and `risk_accepted` terms only.
- **Smallest enterprise-capable version**: Remove acknowledged compatibility end to end from productive findings status constants and helpers, shared query helpers, shared filter and badge catalogs, capability registry and role mappings, workflow-facing tests, and findings-derived summary surfaces while leaving the rest of the findings lifecycle unchanged.
- **Explicit non-goals**: No backfill-runtime-surface removal in this slice, no broader findings lifecycle redesign, no new states, no migration shim, no historical data migration, no verification-acknowledgement cleanup, no onboarding-verification terminology rewrite, and no new customer-facing workflow surface.
- **Permanent complexity imported**: Net negative. The slice removes a legacy status branch, a capability alias, catalog special-casing, and acknowledged-specific workflow expectations. The only enduring obligation is focused regression coverage that proves one canonical status path remains across findings workflows and findings-derived summaries.
- **Why now**: This candidate remains explicitly open in both product sources and is still repo-proven in productive code. It is smaller and more implementation-ready than creation-time invariant hardening, and it does not depend on an external product decision like External Support Desk / PSA Handoff.
- **Why not local**: The compatibility drift is not confined to one screen or helper. It spans the `Finding` model, workflow service, shared filter catalog, badge language, capability registry, role mappings, canonical summary builders, and workflow-facing tests. A local rename would leave inconsistent product truth in other entry points.
- **Approval class**: Cleanup
- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this slice is intentionally limited to canonical status and RBAC vocabulary cleanup only, while creation-time invariants and external support handoff remain explicit follow-up candidates.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Selection Rationale
- `Remove Legacy Acknowledged Finding Status Compatibility` is still active in [docs/product/spec-candidates.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/spec-candidates.md#L172) and [docs/product/implementation-ledger.md](/Users/ahmeddarrazi/Documents/projects/wt-plattform/docs/product/implementation-ledger.md#L183) and remains a concrete semantics blocker instead of a speculative cleanup.
- Repo truth still shows acknowledged drift in the canonical findings model and workflow seams, including `Finding::STATUS_ACKNOWLEDGED`, `Finding::openStatusesForQuery()`, `FilterOptionCatalog::findingStatuses()`, `Capabilities::TENANT_FINDINGS_ACKNOWLEDGE`, and findings-derived summary builders.
- This slice is narrower and safer than `Enforce Creation-Time Finding Invariants` because it removes visible and shared workflow ambiguity first without widening generator hardening or recurrence rules.
- `Cross-Tenant Compare and Promotion v1` is not the next preparation target here because the repo already has refreshed Spec 043 ready for later implementation work.
- `External Support Desk / PSA Handoff` stays deferred because it still depends on a concrete external-desk target and broader commercialization workflow decisions, while this cleanup is fully repo-based today.
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant + canonical-view
- **Primary Routes**:
- `/admin/t/{tenant}/findings`
- `/admin/t/{tenant}/findings/{record}`
- existing canonical `/admin` summary and inbox surfaces that derive open-finding counts or previews from shared findings queries
- existing findings table and filter surfaces that use shared finding-status catalog options
- existing tenant review, review-pack, and support-diagnostic surfaces only where they render findings-derived open-work summaries, counts, or disclosure text
- **Data Ownership**:
- Tenant-owned `Finding` and related `FindingException` truth remain canonical and keep required `workspace_id` plus `tenant_id` anchors.
- Workspace, canonical summary, review/report, and diagnostic consumers stay derived over tenant-owned findings truth; this feature introduces no new persistence, no mirror entity, and no migration data store.
- Historical pre-production findings rows do not justify a compatibility table, alias, or fallback reader.
- **RBAC**:
- Tenant membership remains the isolation boundary for findings visibility and surviving finding workflow mutations.
- Canonical findings workflow permissions stay capability-first and tenant-scoped; `tenant_findings.acknowledge` is removed rather than preserved as an alias.
- Non-members remain deny-as-not-found and entitled members missing surviving findings capabilities remain forbidden on the affected mutation paths.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Canonical `/admin` summary and inbox surfaces that launch from tenant context continue to prefilter to the current tenant, but they must do so with canonical open-status handling only and without an acknowledged compatibility branch.
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace membership and visible-tenant filtering remain authoritative on canonical summary surfaces. The cleanup must not widen findings queries or previews beyond entitled tenants while removing acknowledged compatibility.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, workflow helper text, shared badges, shared filter vocabularies, canonical summary counts and previews, capability language
- **Systems touched**: `App\Models\Finding`, `App\Services\Findings\FindingWorkflowService`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\Auth\Capabilities`, `App\Services\Auth\RoleCapabilityMap`, existing findings resource surfaces, existing findings-derived canonical summary builders, `App\Services\TenantReviews\TenantReviewSectionFactory`, and `App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder`
- **Existing pattern(s) to extend**: shared finding-status helpers, shared filter and badge catalogs, existing findings workflow actions, existing capability registry, and existing canonical summary builders remain the only supported paths
- **Shared contract / presenter / builder / renderer to reuse**: `Finding::openStatuses()`, shared `BadgeCatalog` finding-status semantics, `FilterOptionCatalog::findingStatuses()`, the canonical capability registry, and existing summary builders that already derive open findings from shared helpers
- **Why the existing shared path is sufficient or insufficient**: the shared paths are sufficient once the legacy acknowledged branch is removed. They are the reason this cleanup must land centrally instead of through page-local exceptions or label overrides.
- **Allowed deviation and why**: none
- **Consistency impact**: triage wording, open counts, badges, filters, review/report disclosure text, diagnostic issue summaries, disabled helper text, and role guidance must all converge on the same canonical finding-status language in the same slice.
- **Review focus**: reviewers must verify that no productive code path, shared filter, badge label, capability alias, review/report disclosure, diagnostic summary, or workflow-facing test still treats `acknowledged` as current findings workflow truth.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: existing findings and summary surfaces remain read/write or read-only according to their current workflows; no `OperationRun` start semantics are introduced or removed by this status cleanup.
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider or platform seam is widened. This slice only removes legacy findings workflow compatibility inside the existing findings domain.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Tenant findings register and detail: remove acknowledged wording from statuses, filters, and workflow affordances | yes | Native Filament + shared workflow primitives | row actions, header actions, badges, filters | list, detail, action state | no | Canonical triage language only |
| Canonical findings-derived summaries: governance inbox, workspace overview, customer health, and similar previews use canonical open-status handling only | yes | Native Filament + shared summary builders | dashboard cards, inbox previews, counters, drilldowns | page, widget, query state | no | Summary counts and previews stop carrying a hidden acknowledged branch |
| Shared findings status filters and badges: remove legacy acknowledged option and label | yes | Shared badge and filter catalog primitives | status messaging, filter vocabulary, badge semantics | catalog, table filter state | no | No `legacy acknowledged` affordance remains on productive findings surfaces |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Tenant findings register and detail | Primary Decision Surface | Decide how to triage or continue work on a finding | canonical status, severity, due or SLA signals, responsibility, and canonical workflow actions | evidence, history, related runs, and exception detail after opening the finding | Primary because this is where tenant operators act on findings today | Keeps findings work centered on one canonical lifecycle path | Removes a parallel acknowledged label that competes with the real next action |
| Canonical findings-derived summaries | Secondary Context Surface | Decide where follow-up exists before drilling into findings work | counts, previews, and urgency signals derived from canonical open statuses only | the findings register or detail after navigation | Not primary because these surfaces route operators into findings work rather than owning the mutations themselves | Keeps overview or inbox surfaces honest about what is actually open | Removes mismatched counts and pseudo-open summary branches |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Tenant findings register and detail | operator-MSP | canonical findings status, responsibility, due state, and workflow affordances | history, evidence, exception detail, and related runs after opening the record | raw or support-only detail remains on existing deeper routes and capability gates | `Triage finding` or continue canonical workflow | low-level evidence and audit detail stay secondary | status is stated once in canonical terms and deeper sections add evidence rather than alternate vocabulary |
| Canonical findings-derived summaries | operator-MSP | canonical counts, previews, and urgency signals only | secondary drilldowns to the findings register and detail | raw evidence is never the default content on the summary surface | `Open findings` | detailed evidence and audit context stay on deeper surfaces | summaries describe open work once and rely on the findings register for detailed truth |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant findings register and detail | List / Table / Bulk | CRUD / List-first Resource | Open a finding and continue the canonical workflow | full-row navigation to finding detail | required | existing row `More` actions and detail-header actions only | existing destructive-like actions remain in grouped or detail-header placements | `/admin/t/{tenant}/findings` | `/admin/t/{tenant}/findings/{record}` | tenant context, status filters, responsibility, due state | Findings / Finding | canonical findings workflow state and urgency | none |
| Canonical findings-derived summaries | Monitoring / Queue / Workbench | Context-first summary and preview shell | open a filtered findings view for the relevant tenant or queue | card or preview drilldown into the findings register | forbidden | secondary links only | none | existing canonical `/admin` summary and inbox pages | `/admin/t/{tenant}/findings` | workspace context, tenant filter, preview scope | Findings follow-up / Findings follow-up | where open work exists under the canonical status set | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant findings register and detail | Tenant operator | Decide how to triage, assign, continue, resolve, close, or risk-accept a finding | List/detail | What should I do next with this finding? | canonical status, severity, responsibility, due or SLA state, and current workflow affordances | evidence, exception history, audit context, related operations | lifecycle, urgency, responsibility, governance validity | TenantPilot only for the existing findings workflow actions | Triage, Start progress, Assign, Resolve, Risk accept | existing destructive-like workflow actions only |
| Canonical findings-derived summaries | Workspace or portfolio operator | Decide where follow-up exists before drilling into findings work | Summary and preview | Where is open findings work waiting right now? | canonical counts, previews, due urgency, and tenant context | deeper findings detail after navigation | lifecycle and urgency only | none on the summary surface itself | Open findings | none |
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Unit, Heavy-Governance
- **Validation lane(s)**: fast-feedback, confidence, heavy-governance
- **Why this classification and these lanes are sufficient**: focused feature coverage proves canonical findings workflows and findings-derived summaries stop exposing acknowledged semantics, while narrow unit coverage proves the shared status helper, filter catalog, and capability cleanup stay centralized. One retained heavy-governance guard layer remains appropriate because status-like badge/filter drift can reappear through shared seams even after the main behavior is corrected.
- **New or expanded test families**: focused findings workflow cleanup coverage, focused findings-summary consistency coverage, and bounded filter or capability cleanup coverage. No browser family is introduced.
- **Fixture / helper cost impact**: low and likely net-neutral to slightly negative. Acknowledged-only fixtures and compatibility expectations should be consolidated or deleted instead of adding new heavy setup.
- **Heavy-family visibility / justification**: retained shared guard coverage for status-like tokens and filter-catalog usage remains explicit so a local reintroduction of acknowledged semantics cannot survive through shared UI seams. No new heavy-governance family is introduced.
- **Special surface test profile**: standard-native-filament, global-context-shell
- **Standard-native relief or required special coverage**: standard Filament and domain coverage are sufficient for the findings resource and canonical summaries. Required extra proof is shared guard coverage for badge and filter drift.
- **Reviewer handoff**: reviewers must confirm that acknowledged disappears together from the model helper, workflow rules, shared filter options, shared badge language, role/capability vocabulary, and summary counts or previews. They must also confirm that verification acknowledgement and onboarding acknowledgement remain untouched.
- **Budget / baseline / trend impact**: net-neutral to slightly negative because the slice should remove acknowledged-only compatibility expectations rather than widen the suite.
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
## RBAC / Isolation Considerations
- Tenant findings mutations remain tenant-scoped and follow existing deny-as-not-found versus forbidden semantics: non-members remain `404`, entitled members missing a surviving findings capability remain `403` on mutation.
- Canonical `/admin` summary and inbox surfaces continue to derive only from tenants the actor can see in the current workspace. Removing acknowledged compatibility must not broaden canonical previews or counts beyond the current visible-tenant boundary.
- `tenant_findings.acknowledge` is removed rather than preserved as an alias. Canonical findings workflow language stays capability-first and centered on the surviving findings capabilities.
- This slice does not add a new role family, a new authorization plane, or a new hidden compatibility bypass.
## Auditability
- No new audit action ID is introduced by this cleanup.
- Existing findings workflow audit actions such as triage, assignment, in-progress, resolve, close, reopen, and risk acceptance remain the canonical audit language for surviving workflow actions.
- Pre-production historical acknowledgement fields or audit rows do not justify a compatibility renderer, label shim, or preserved active-workflow vocabulary.
- The implementation should ensure that operator-facing surfaces do not continue to advertise `acknowledged` as a current workflow outcome merely because historical audit or fixture data once used it.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Use One Canonical Findings Workflow Language (Priority: P1)
As a tenant operator, I want findings surfaces to speak in one canonical workflow language so I can triage and continue work without guessing whether `acknowledged` and `triaged` still mean different things.
**Why this priority**: This is the core user-facing value of the cleanup. If findings still speak two different workflow languages, the slice has failed.
**Independent Test**: Open the findings register and detail for a tenant with open findings and verify that statuses, filters, badges, and workflow affordances use canonical findings vocabulary only.
**Acceptance Scenarios**:
1. **Given** an entitled tenant operator opens the findings register, **When** the page renders, **Then** no current status badge, filter option, or helper text exposes `acknowledged` as a valid findings workflow state.
2. **Given** an open finding is ready for triage or progress, **When** the operator uses the surviving workflow affordances, **Then** the workflow uses canonical `triaged` semantics rather than an acknowledge alias.
3. **Given** a finding has already reached a terminal state, **When** the operator opens it, **Then** the terminal states remain unchanged and no new replacement state is introduced.
---
### User Story 2 - Keep Summary Counts, Reports, and Diagnostics Honest (Priority: P1)
As a workspace or portfolio operator, I want shared counts, previews, review/report disclosures, and diagnostic summaries to match the findings register so I can trust what is actually open without a hidden acknowledged branch skewing the numbers.
**Why this priority**: Shared summary drift is one of the main reasons this cleanup cannot stay local. If summaries, review/report disclosures, diagnostics, and lists diverge, operators lose trust in the overview surfaces.
**Independent Test**: Compare the findings register with the affected canonical summary and inbox surfaces plus findings-derived review/report and support-diagnostic consumers for the same tenant or workspace context and verify that the same canonical open-status set drives all of them.
**Acceptance Scenarios**:
1. **Given** a canonical summary surface shows open findings work for a tenant, **When** the operator drills into the findings register, **Then** the summary counts and previews align with the same canonical open-status set.
2. **Given** the operator changes tenant or workspace context, **When** canonical summaries reload, **Then** they continue to derive findings work only from canonical open statuses and the currently entitled tenant set.
3. **Given** a tenant review, review-pack, or support-diagnostic surface renders findings-derived open-work disclosure, **When** the operator opens that surface, **Then** the disclosure text, counts, and issue grouping use the same canonical open-status set and do not present `acknowledged` as current work.
---
### User Story 3 - Keep RBAC Language Canonical (Priority: P2)
As a tenant manager or owner, I want role guidance and capability-driven findings actions to use one canonical permission language so workflow help and disabled states stay understandable.
**Why this priority**: The acknowledged alias is not only a status problem. It also keeps role and action guidance inconsistent across the same workflow.
**Independent Test**: Review capability-driven findings surfaces and role expectations after the cleanup and verify that they reference surviving findings capabilities only.
**Acceptance Scenarios**:
1. **Given** a tenant member can triage findings, **When** the findings UI explains or gates the action, **Then** it refers to the canonical triage capability and not an acknowledge alias.
2. **Given** a tenant member lacks the required surviving capability, **When** they attempt the affected findings action, **Then** the existing forbidden behavior remains and no acknowledge-specific permission branch is used.
### Edge Cases
- Local or historical pre-production rows may still contain `acknowledged`; this slice does not add a migration shim, fallback reader, or preserved UI label to keep that branch alive.
- Removing acknowledged from shared helpers must update list surfaces, summary counts, preview queries, filters, and badges together; otherwise the cleanup would create a new mismatch instead of removing one.
- Verification-check acknowledgement and onboarding-verification acknowledgement are separate domains and must not be renamed or removed as collateral damage in this slice.
- Resolved, closed, and risk-accepted findings behavior remains distinct and must not be collapsed while removing the acknowledged compatibility path.
## Requirements *(mandatory)*
**Constitution alignment (LEAN-001 / STATE-001 / SPEC-GATE-001):** This is a pre-production cleanup slice. It removes a legacy findings workflow branch rather than introducing new state, persistence, or abstraction. Compatibility shims, fallback readers, historical fixture preservation, and capability aliases are out of scope.
**Constitution alignment (XCUT-001 / BADGE-001):** Because the drift survives in shared status helpers, shared filter catalogs, shared badge language, and shared summary builders, the cleanup must land through the shared paths themselves. No page-local override or secondary presenter may keep acknowledged alive.
**Constitution alignment (RBAC-UX):** Tenant membership and capability rules stay unchanged except for removing the acknowledged alias from the findings capability vocabulary. Non-members remain `404`; entitled members missing the surviving capability remain `403` on mutations.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature, unit, and bounded heavy-governance guard coverage. The slice should remove acknowledged-only expectations rather than creating a broader or heavier new test family.
**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / OPSURF-001):** Findings surfaces remain native Filament or shared summary surfaces. Canonical findings vocabulary must stay consistent across badges, filters, helper text, row actions, disabled states, and drilldown summaries without introducing a new local status language.
### Functional Requirements
- **FR-254-001**: The system MUST retire `acknowledged` as a productive findings workflow status and remove any status helper that treats it as a current canonical findings state.
- **FR-254-002**: Shared open-status query helpers and findings-derived summary builders MUST rely on the canonical open findings status set only and MUST NOT preserve a hidden acknowledged compatibility branch.
- **FR-254-003**: Shared findings filter catalogs, status badges, and related helper text MUST stop exposing `acknowledged` or `legacy acknowledged` as a valid findings workflow affordance.
- **FR-254-004**: Findings workflow actions and guards MUST authorize and mutate against canonical triage semantics only; the active findings workflow must not require or preserve an acknowledge alias.
- **FR-254-005**: The canonical capability registry, role mappings, and workflow-facing authorization expectations MUST remove `tenant_findings.acknowledge` rather than keeping it as a stale alias.
- **FR-254-006**: Productive code paths and workflow-facing tests MUST stop writing, expecting, or advertising `acknowledged` as a valid current findings workflow status.
- **FR-254-007**: Existing findings flows remain functional and in scope only for regression protection across `new`, `triaged`, `in_progress`, `reopened`, `resolved`, `closed`, and `risk_accepted` outcomes.
- **FR-254-008**: The feature MUST NOT reintroduce repair tooling, backfill semantics, new workflow states, migration shims, fallback readers, or historical compatibility logic to preserve the removed acknowledged branch.
- **FR-254-009**: The feature MUST NOT alter verification-check acknowledgement, onboarding-verification acknowledgement, or other non-finding acknowledgement domains unless a path directly depends on findings status compatibility, in which case that dependency must be removed instead of widening the slice.
- **FR-254-010**: Tenant-owned findings keep existing `workspace_id` plus `tenant_id` ownership anchors; no new persisted alias, auxiliary mapping table, or compatibility truth is introduced.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Findings resource list/detail | `app/Filament/Resources/FindingResource.php` and related pages | no acknowledge-named action or helper text remains; surviving workflow utilities keep canonical wording | full-row click to finding detail remains canonical | existing canonical findings workflow actions only | existing grouped bulk actions only, with no acknowledged vocabulary | existing empty state remains unchanged | existing detail-header workflow actions keep canonical wording only | `N/A` | yes, unchanged for surviving workflow actions | Remove acknowledged vocabulary from filters, badges, disabled helper text, and action guidance without introducing a replacement action |
| Canonical findings summaries and inbox shells | existing `/admin` pages and widgets using shared findings summary builders | no new header actions; drilldown links only | same-page cards, counters, or preview links remain canonical | none | none | existing empty states remain, but they must not mention acknowledged compatibility | `N/A` | `N/A` | no new audit event | This slice updates counts, previews, and wording only |
### Key Entities *(include if feature involves data)*
- **Canonical finding status**: The current findings lifecycle language used on productive findings surfaces and queries. After this cleanup it consists only of the surviving canonical statuses already present in the findings workflow.
- **Findings-derived summary surface**: Any canonical `/admin` overview, inbox, widget, or preview surface that derives open findings work from the shared finding-status helper rather than from a local list of status strings.
- **Findings capability mapping**: The shared capability and role-mapping truth that determines which tenant members can use the surviving findings workflow actions.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Zero productive findings filters, badges, helper texts, or workflow affordances expose `acknowledged` as a current findings workflow status.
- **SC-002**: Zero supported findings permissions or role mappings expose `tenant_findings.acknowledge` after the cleanup.
- **SC-003**: All in-scope findings-derived summary surfaces and findings register surfaces use the same canonical open-status set during regression validation.
- **SC-004**: Representative regression proof still passes for the surviving findings workflow from `new` through `triaged`, `in_progress`, `resolved`, and `risk_accepted` outcomes without introducing a replacement compatibility branch.
## Dependencies
- The canonical findings model and workflow seams where acknowledged compatibility still survives, including the shared findings status helper, workflow service, filter catalog, and capability registry.
- Existing findings-derived summary builders that currently rely on shared open-status helpers for inbox, health, and preview surfaces.
- Existing findings resource and workflow-facing tests that still preserve or assert acknowledged semantics.
## Assumptions
- The current repo truth treats `triaged` as the canonical operator-facing findings workflow semantics and keeps `acknowledged` only as cleanup debt.
- LEAN-001 still applies because the product remains pre-production; historical or local findings rows do not justify compatibility behavior in this slice.
- Spec 253 already covers the adjacent backfill-runtime-surface cleanup, so this slice should not reopen that work while cleaning status semantics.
- Cross-Tenant Compare and Promotion is already refreshed as Spec 043 and is therefore not the next open preparation target here.
- Verification-check acknowledgement remains a separate domain and must not be pulled into this findings cleanup.
## Risks
- Hidden acknowledged residues may still survive in shared summary builders, status badges, or old test fixtures even after the main findings workflow seam is cleaned.
- Local or stale pre-production data containing acknowledged may surface unexpected failures if implementation removes compatibility before all relevant fixtures and productive write paths are updated together.
- Overbroad cleanup could accidentally touch verification or onboarding acknowledgement semantics, which would violate the intended slice boundary.
## Out of Scope
- Removing or revisiting the already-separated backfill-runtime-surface cleanup slice
- Enforcing creation-time finding invariants or generator hardening beyond what is needed to stop preserving acknowledged compatibility
- Broader findings lifecycle redesign, new workflow states, or new customer-facing workflow surfaces
- Historical data migration, translation helpers, fallback readers, or compatibility-specific test preservation
- Verification-check acknowledgement, onboarding acknowledgement UX, or non-finding acknowledgement domains
- External Support Desk / PSA Handoff or other commercialization workflow work
## Follow-up Candidates
1. `Enforce Creation-Time Finding Invariants` remains the next findings hardening candidate after this semantics cleanup because generator and recurrence guarantees still need explicit protection.
2. `External Support Desk / PSA Handoff` remains an explicit deferred candidate for commercialization flow maturity once the repo names a concrete external desk target.
3. `Cross-Tenant Compare and Promotion v1` remains covered by refreshed Spec 043 and should continue on that track instead of being reopened inside this cleanup slice.

View File

@ -1,238 +0,0 @@
# Tasks: Remove Legacy Acknowledged Finding Status Compatibility
**Input**: Design documents from `/specs/254-remove-acknowledged-compat/`
**Prerequisites**: `plan.md`, `spec.md`, `checklists/requirements.md`
**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` lanes named in `specs/254-remove-acknowledged-compat/plan.md`, plus the retained `heavy-governance` guards already called out there. Prefer focused new proof in `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php`, `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php`, `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php`, and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`, then prove summary convergence through the existing downstream customer-health, governance-inbox, baseline, alert, tenant-review, and support-diagnostics tests instead of widening the suite.
**Operations**: This slice does not add or change an `OperationRun` start family, does not add a new audit action ID, and must not introduce migration shims, fallback readers, or repair tooling.
**RBAC**: Preserve current `/admin/t/{tenant}` tenant isolation, deny-as-not-found `404` for non-members or out-of-scope users, and `403` for in-scope capability failures on surviving findings actions. Remove `tenant_findings.acknowledge` from the capability registry and role mappings without widening any unrelated authorization behavior.
**UI / Surface Guardrails**: This is a `review-mandatory` cleanup across native Filament findings surfaces and canonical `/admin` summary or inbox shells. Keep `standard-native-filament` relief for the tenant findings resource and `global-context-shell` proof for workspace summaries, and do not add panels, assets, local status presenters, or replacement workflow affordances.
**Badges / Filters (BADGE-001 / XCUT-001)**: Remove the legacy acknowledged branch through shared findings status seams only. `BadgeCatalog`, `FilterOptionCatalog`, and the existing findings resource or summary builders remain the supported paths; no page-local mapping or one-off status label is allowed.
**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` and `US2` in parallel -> `US3` -> final cleanup and validation, because canonical RBAC proof only matters after the workflow and summary surfaces stop carrying acknowledged compatibility.
**Implementation note**: Several downstream consumers already converged automatically once `Finding::openStatusesForQuery()` and the shared RBAC/filter seams were corrected. Where direct edits in the originally listed consumer files proved unnecessary, completion below reflects the shared-helper cleanup plus targeted validation in the existing downstream tests rather than redundant file-local rewrites.
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback` plus `confidence`, with retained `heavy-governance` guards only in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, and remains the narrowest sufficient proof for the removed compatibility branch.
- [x] New or changed tests stay in focused `Feature` and `Unit` files only; no browser lane or new heavy-governance family is added.
- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; acknowledged-specific setup is deleted or localized instead of becoming a new shared default.
- [x] Planned validation commands stay limited to the targeted Sail test commands captured in `specs/254-remove-acknowledged-compat/plan.md` and the final validation phase below.
- [x] The declared surface test profile stays `standard-native-filament` plus `global-context-shell`; no additional surface exception is introduced.
- [x] Any material suite-footprint or residue follow-up resolves inside this feature as `document-in-feature` or `reject-or-split`, not as silent scope drift.
## Phase 1: Setup (Shared Cleanup Anchors)
**Purpose**: Lock the concrete removal inventory, out-of-scope boundaries, and proving commands before implementation starts.
- [x] T001 [P] Verify the productive acknowledged inventory across `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Support/Auth/Capabilities.php`, `apps/platform/app/Services/Auth/RoleCapabilityMap.php`, `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T002 [P] Verify the shared summary, alert, and query-consumer inventory across `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, and `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`
- [x] T003 [P] Verify the out-of-scope acknowledgement domains stay untouched across `apps/platform/app/Services/Verification/VerificationCheckAcknowledgementService.php`, `apps/platform/app/Filament/Support/VerificationReportViewer.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/tests/Feature/Verification/VerificationCheckAcknowledgementTest.php`, and `apps/platform/tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php`
- [x] T004 [P] Verify the minimum Sail validation commands and file-scoped coverage expectations in `specs/254-remove-acknowledged-compat/plan.md`, `specs/254-remove-acknowledged-compat/spec.md`, and `specs/254-remove-acknowledged-compat/checklists/requirements.md`
**Checkpoint**: The cleanup boundaries, shared seams, and proving commands are locked before any runtime file is changed.
---
## Phase 2: Foundational (Blocking Proof Surfaces)
**Purpose**: Make the intended regression proof and stale compatibility inventory explicit before removing shared semantics.
**CRITICAL**: No user story work should begin until this phase is complete.
- [x] T005 [P] Create the core status and workflow proof files `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php`
- [x] T006 [P] Create the shared-surface and RBAC proof files `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php` and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`, and prove downstream summary consumers through the existing baseline, workspace health, tenant review, support-diagnostics, and alerts tests that already exercise the shared open-status helper.
- [x] T007 [P] Verify retained guard coverage in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` so findings acknowledged compatibility is treated as removed repo truth rather than tolerated legacy drift
- [x] T008 [P] Audit stale compatibility fixtures and helpers across `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, and `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`
**Checkpoint**: Proof files, guard expectations, and stale compatibility anchors are explicit and ready for bounded implementation work.
---
## Phase 3: User Story 1 - Use One Canonical Findings Workflow Language (Priority: P1) 🎯 MVP
**Goal**: Remove acknowledged as a productive findings lifecycle concept so tenant findings list, detail, inbox, badges, and filters all speak in one canonical workflow language.
**Independent Test**: Open the tenant findings register, detail, and assignee inbox for a tenant with active findings and verify that statuses, filters, badges, helper text, and workflow affordances use canonical findings vocabulary only.
### Tests for User Story 1
- [x] T009 [P] [US1] Add canonical status-contract and lifecycle assertions in `apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php`
- [x] T010 [P] [US1] Add shared filter, badge, and list-surface absence proof in `apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`, and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`
### Implementation for User Story 1
- [x] T011 [US1] Remove productive acknowledged status constants, canonicalization shims, `acknowledge()` helper behavior, and acknowledged factory state usage from `apps/platform/app/Models/Finding.php` and `apps/platform/database/factories/FindingFactory.php`
- [x] T012 [US1] Collapse workflow transitions and policy checks onto canonical triage semantics in `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/app/Policies/FindingPolicy.php`
- [x] T013 [US1] Remove acknowledged filter options, acknowledged detail metadata copy, and acknowledged-specific visibility branches from `apps/platform/app/Support/Filament/FilterOptionCatalog.php`, `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T014 [US1] Rewrite stale workflow helper and compatibility assertions in `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, and `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php`
**Checkpoint**: User Story 1 is independently functional and no productive findings surface still advertises acknowledged as current workflow truth.
---
## Phase 4: User Story 2 - Keep Summary Counts and Previews Honest (Priority: P1)
**Goal**: Make every shared summary, preview, alert, and consumer query derive open findings from the same canonical status set used by the findings register.
**Independent Test**: Compare the tenant findings register with the affected `/admin` summary and inbox surfaces for the same tenant or workspace context and verify that counts, previews, drilldowns, and alerts align with the same canonical open-status set.
### Tests for User Story 2
- [x] T015 [P] [US2] Prove shared summary alignment through the existing downstream tests in `apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php` and `apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php`, with the governance-inbox stale-operations expectation recorded separately as an unrelated existing operation-age failure.
- [x] T016 [P] [US2] Add consumer regression coverage for overview, baseline, tenant-review, diagnostics, and alert paths in `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php`, `apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php`, and `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`
### Implementation for User Story 2
- [x] T017 [US2] Collapse canonical open-status summary builders via the shared `Finding::openStatusesForQuery()` cleanup and validate the unchanged consumers in `apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php`, `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php`, and `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- [x] T018 [US2] Remove acknowledged-specific active-count and review-report branches from `apps/platform/app/Support/Baselines/BaselineCompareStats.php` while validating that `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` and `apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php` already converged through the shared open-status helper.
- [x] T019 [US2] Collapse alert, generator, and hygiene consumers onto canonical open statuses via the shared helper in `apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/Baselines/BaselineAutoCloseService.php`, and `apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php`
- [x] T020 [US2] Rewrite acknowledged-compatibility expectations in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and validate the unaffected downstream consumers in `apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, and `apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneOverviewSignalTest.php`
**Checkpoint**: User Story 2 is independently functional and shared counts, previews, review packs, diagnostics, and alerts all reflect the same canonical findings-open set.
---
## Phase 5: User Story 3 - Keep RBAC Language Canonical (Priority: P2)
**Goal**: Remove the acknowledge capability alias so role guidance, authorization checks, and disabled findings actions all use the surviving canonical findings permission language only.
**Independent Test**: Review capability-driven findings surfaces and role expectations after the cleanup and verify that they reference surviving findings capabilities only while preserving current `404` versus `403` semantics.
### Tests for User Story 3
- [x] T021 [P] [US3] Add positive and negative capability-alias removal coverage in `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php`
- [x] T022 [P] [US3] Update role-matrix and UI-enforced findings permission proof in `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`, and validate the surviving UI-enforced surface contract in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
### Implementation for User Story 3
- [x] T023 [US3] Remove `tenant_findings.acknowledge` from `apps/platform/app/Support/Auth/Capabilities.php` and `apps/platform/app/Services/Auth/RoleCapabilityMap.php`
- [x] T024 [US3] Collapse acknowledge-specific authorization branches and findings UI enforcement onto surviving capabilities in `apps/platform/app/Policies/FindingPolicy.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, and `apps/platform/app/Filament/Resources/FindingResource.php`
- [x] T025 [US3] Rewrite stale RBAC and capability-alias expectations in `apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`, `apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`, and `apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`
**Checkpoint**: User Story 3 is independently functional and no supported findings permission path or role expectation still names an acknowledge alias.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Remove remaining stale compatibility residue, keep scope boundaries honest, and run the narrow validation workflow.
- [x] T026 [P] Remove final acknowledged-compatibility residue from findings-only helper and proof surfaces in `apps/platform/tests/Feature/Models/FindingResolvedTest.php`, `apps/platform/tests/Feature/Findings/Concerns/InteractsWithFindingsWorkflow.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Unit/Badges/FindingBadgesTest.php`, and `apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php` after the story-specific rewrites land
- [x] T027 [P] Run a residue search for `STATUS_ACKNOWLEDGED`, `TENANT_FINDINGS_ACKNOWLEDGE`, `legacy acknowledged`, and `acknowledge(` across `apps/platform/app/`, `apps/platform/database/factories/`, and `apps/platform/tests/`, then classify any remaining match as in-scope cleanup, allowed non-finding domain, or `reject-or-split`
- [x] T028 Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` after the cleanup across `apps/platform/app/`, `apps/platform/database/factories/`, and `apps/platform/tests/`
- [x] T029 [P] Run the focused workflow, filter, and RBAC Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php tests/Unit/Findings/FindingStatusSemanticsTest.php tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php`
- [x] T030 [P] Run the focused summary and consumer Sail command using the existing downstream proof files `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php tests/Feature/Baselines/BaselineCompareStatsTest.php tests/Feature/Alerts/SlaDueAlertTest.php`, with the governance-inbox stale-operations expectation recorded as an unrelated existing failure outside the acknowledged-status cleanup.
- [x] T031 [P] Run the retained heavy-governance guards `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, then verify no out-of-scope verification or onboarding acknowledgement file from `T003` changed without an explicit split decision
- [x] T032 [P] Verify FR-254-010 explicitly by confirming `Finding` keeps `workspace_id` plus `tenant_id` as unchanged ownership anchors and that no file under `apps/platform/database/migrations/` changed and no new persisted compatibility artifact, alias table, fallback reader, or migration-backed truth was introduced while implementing this slice; if ownership-anchor or persistence widening appears, stop and split the work instead of absorbing it here
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and locks the exact removal inventory, boundaries, and proving commands.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, guard expectations, and stale compatibility anchors are explicit.
- **User Story 1 (Phase 3)**: Depends on Foundational and is part of the MVP delivery.
- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it targets shared summary, alert, and query consumers behind the same canonical status set.
- **User Story 3 (Phase 5)**: Depends on User Story 1 because RBAC language should be validated only after findings workflow surfaces stop advertising acknowledged semantics; it can overlap with late User Story 2 cleanup once capability surfaces are isolated.
- **Polish (Phase 6)**: Depends on all desired user stories being complete so residue checks, formatting, and focused validation run on the final cleanup shape.
### User Story Dependencies
- **US1**: No dependencies beyond Foundational.
- **US2**: No dependencies beyond Foundational.
- **US3**: Depends on US1 and should validate alongside completed US2 summary cleanup.
### Within Each User Story
- Add or update the story tests first and confirm they fail before cleanup edits are considered complete.
- Remove shared compatibility branches centrally instead of hiding acknowledged semantics on one page or in one helper.
- Do not keep compatibility aliases, fallback readers, data-migration shims, or replacement workflow affordances.
- Keep backfill-runtime-surface removal, creation-time invariants hardening, broader lifecycle redesign, verification acknowledgement cleanup, onboarding acknowledgement cleanup, and support-desk work out of scope.
### Parallel Opportunities
- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup.
- `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work.
- `T009` and `T010` can run in parallel for User Story 1 before `T011`, `T012`, `T013`, and `T014`.
- `T015` and `T016` can run in parallel for User Story 2 before `T017`, `T018`, `T019`, and `T020`.
- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete.
- `T021` and `T022` can run in parallel for User Story 3 before `T023`, `T024`, and `T025`.
- `T029`, `T030`, and `T031` can run in parallel during final validation.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T009 apps/platform/tests/Unit/Findings/FindingStatusSemanticsTest.php + apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php
T010 apps/platform/tests/Unit/Support/Filament/FindingStatusFilterCatalogTest.php + apps/platform/tests/Unit/Badges/FindingBadgesTest.php + apps/platform/tests/Feature/Support/Badges/FindingBadgeTest.php + apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
# User Story 1 implementation after the tests are in place
T011 apps/platform/app/Models/Finding.php + apps/platform/database/factories/FindingFactory.php
T012 apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Policies/FindingPolicy.php
T013 apps/platform/app/Support/Filament/FilterOptionCatalog.php + apps/platform/app/Filament/Resources/FindingResource.php + apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php + apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T015 apps/platform/tests/Unit/Support/CustomerHealth/WorkspaceHealthSummaryQueryTest.php + apps/platform/tests/Unit/Support/GovernanceInbox/GovernanceInboxSectionBuilderTest.php
T016 apps/platform/tests/Feature/Filament/WorkspaceOverviewSummaryMetricsTest.php + apps/platform/tests/Feature/Filament/WorkspaceOverviewGovernanceAttentionTest.php + apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php + apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php + apps/platform/tests/Feature/TenantReview/TenantReviewExecutivePackTest.php + apps/platform/tests/Unit/Support/SupportDiagnostics/SupportDiagnosticBundleBuilderTest.php + apps/platform/tests/Feature/Alerts/SlaDueAlertTest.php
# User Story 2 implementation after the tests are in place
T017 apps/platform/app/Support/CustomerHealth/WorkspaceHealthSummaryQuery.php + apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php + apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php + apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
T018 apps/platform/app/Support/Baselines/BaselineCompareStats.php + apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php + apps/platform/app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php
T019 apps/platform/app/Jobs/Alerts/EvaluateAlertsJob.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php + apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/Baselines/BaselineAutoCloseService.php + apps/platform/app/Services/Findings/FindingAssignmentHygieneService.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T021 apps/platform/tests/Feature/Auth/RemoveAcknowledgedCapabilityAliasTest.php + apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php
T022 apps/platform/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php + apps/platform/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php + apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php
# User Story 3 implementation after the tests are in place
T023 apps/platform/app/Support/Auth/Capabilities.php + apps/platform/app/Services/Auth/RoleCapabilityMap.php
T024 apps/platform/app/Policies/FindingPolicy.php + apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Filament/Resources/FindingResource.php
```
---
## Implementation Strategy
### MVP First (User Stories 1 and 2)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Complete Phase 4: User Story 2.
5. Run `T028`, `T029`, and `T030` before widening into capability-alias cleanup.
### Incremental Delivery
1. Lock the shared seams, out-of-scope domains, and proving commands.
2. Remove acknowledged from the findings model, workflow, filter, badge, and Filament surfaces.
3. Remove acknowledged from summary builders, review or report helpers, alerts, and other shared query consumers.
4. Remove the stale capability alias and role expectations once findings workflow language is already canonical.
5. Finish with residue searches, formatting, and the focused Sail commands.
### Parallel Team Strategy
1. One contributor can own the findings model, workflow, and Filament cleanup (`US1`) while another owns shared summaries, alerts, review helpers, and query consumers (`US2`) after Phase 2.
2. Once the two P1 stories land, a focused pass can remove the capability alias and RBAC wording (`US3`) without reopening summary or workflow decisions.
3. A final pass can remove stale residue, run Pint, and execute the three focused Sail validation commands.
---
## Notes
- Suggested MVP scope: Phase 1 through Phase 4 only. Canonical workflow cleanup without summary or query-consumer alignment is not sufficient for this feature.
- Explicit non-goals remain: backfill-runtime-surface removal, creation-time invariant hardening, broader lifecycle redesign, verification or onboarding acknowledgement cleanup, new workflow states, migration shims, repair tooling, and support-desk workflows.
- Filament stays on Livewire v4 and no panel/provider or asset strategy changes are needed; `FindingResource` already has a view page, so global-search behavior does not need separate tasking in this slice.
- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths.

View File

@ -1,48 +0,0 @@
# Specification Quality Checklist: Enforce Creation-Time Finding Invariants
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-29
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Repo-specific classes, routes, file paths, and validation commands appear only where they are required to keep the three active writer families and proof obligations unambiguous
- [x] Focused on user value and business needs
- [x] Written for product and review stakeholders, with repo-grounded detail only where the bounded invariant target would otherwise stay ambiguous
- [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 stay outcome-oriented even though the package names concrete writer families and proof files needed to bound the slice
- [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 unbounded implementation plan leaks into the specification; repo-specific commands and paths stay limited to selection, dependency, and validation context
## Test Governance Review
- [x] Lane fit is explicit: the package uses `fast-feedback` and `confidence`, with the three writer suites as the primary proof and only bounded recurrence, consumer, and trigger-authorization regressions where FR-255-005, FR-255-006, FR-255-009, and FR-255-011 require them.
- [x] No new browser or heavy-governance family is introduced; adjacent proof remains inside existing feature suites only.
- [x] Suite-cost outcome stays bounded and reviewable: the package reuses existing writer, recurrence, consumer, and auth suites without adding a new default-heavy harness.
## Review Outcome
- [x] Review outcome class: `acceptable-special-case`
- [x] Workflow outcome: `keep`
- [x] Review-note location is explicit: guardrail, lane-fit, and bounded-proof notes live in `spec.md`, `plan.md`, `tasks.md`, and this checklist.
## Notes
- Repo-surface names, validation commands, and current writer/test anchors are intentionally present because this prep package must distinguish the three active finding writers from already-completed adjacent cleanup specs.
- The spec remains behavior-first: write-time lifecycle readiness, recurrence identity, reopen truth, and unchanged RBAC/tenant isolation are the product outcomes; repo details only keep the package reviewable and bounded.
- No blocking open question remains for safe planning.

View File

@ -1,101 +0,0 @@
version: 1
kind: finding-creation-invariants
scope:
goal: enforce lifecycle-ready finding creation and recurrence or reopen semantics across the active finding writers only
non_goals:
- repair tooling or backfill runtime surfaces
- new workflow states or new findings lifecycle families
- customer-facing workflow expansion
- compare refresh work
- external support handoff
- broader findings redesign
- silent database-constraint rollout
stop_conditions:
- another shipped finding writer is discovered outside the three confirmed paths
- application-level write enforcement proves insufficient without a migration or DB constraint
- the only available implementation shape is a new generic invariant framework
active_writer_families:
baseline_compare:
owner_files:
- apps/platform/app/Jobs/CompareBaselineToTenantJob.php
identity:
canonical_key: recurrence_key
fingerprint_contract: fingerprint equals recurrence_key
observation_boundary:
duplicate_guard: current_operation_run_id prevents double counting the same compare run
entra_admin_roles:
owner_files:
- apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
identity:
canonical_key: existing role-assignment or aggregate fingerprint
observation_boundary:
duplicate_guard: later observedAt advances seen history
permission_posture:
owner_files:
- apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
identity:
canonical_key: existing permission or error fingerprint
observation_boundary:
duplicate_guard: later observedAt advances seen history
shared_lifecycle_contract:
model:
owner_file: apps/platform/app/Models/Finding.php
invariants:
- workspace_id and tenant_id remain required ownership anchors
- no new status or reason-code family is introduced
reopen_service:
owner_file: apps/platform/app/Services/Findings/FindingWorkflowService.php
requirement:
- terminal findings reopen only through reopenBySystem
- reopened_at is set
- resolved and closed markers clear according to current service behavior
- sla_days and due_at are recalculated from reopenedAt
- existing audit and alert side effects are preserved
lifecycle_invariants:
create:
required_fields:
- status is new
- first_seen_at equals observedAt
- last_seen_at equals observedAt
- times_seen equals 1
- sla_days is initialized when the current severity policy returns a value
- due_at is initialized when the current severity policy requires due-state truth
contextual_fields:
- current_operation_run_id remains populated where the current writer already sets it
refresh_existing:
required_behavior:
- the same canonical finding identity is reused
- missing first_seen_at, last_seen_at, and times_seen are repaired inline
- missing sla_days or due_at covered by this slice are repaired inline without a second-pass repair tool
- already-valid lifecycle fields are not reset unnecessarily
reopen:
required_behavior:
- the same canonical finding identity is reopened, not duplicated
- resolved_at and resolved_reason clear on reopen
- first_seen_at is retained
- last_seen_at and times_seen advance according to the family observation rule
downstream_regression_consumers:
findings_surfaces:
owner_files:
- apps/platform/app/Filament/Resources/FindingResource.php
- apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php
- apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php
- apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php
expectation:
- no design change is required; these surfaces should continue to read truthful due_at and reopened_at data from the same Finding records
validation_expectations:
required_feature_proof:
- baseline compare proves create readiness, same-run retry protection, reopened reuse, and inline repair of incomplete lifecycle fields
- Entra admin roles proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields
- permission posture proves create readiness, repeated observation, reopened reuse, and inline repair of incomplete lifecycle fields
excluded_lanes:
- browser
- heavy-governance
migration_posture:
- no new migration or schema artifact is allowed in this slice

View File

@ -1,130 +0,0 @@
# Data Model — Enforce Creation-Time Finding Invariants
**Spec**: [spec.md](spec.md)
This feature introduces no new persisted truth. The data-model impact is to make the existing `Finding` lifecycle contract explicit at create, refresh, and reopen time across the three active writer families.
## Existing Canonical Entities Reused
### Finding (`findings`)
**Purpose**: Tenant-owned findings workflow truth.
**Key fields already in use**:
- `id`
- `workspace_id`
- `tenant_id`
- `finding_type`
- `source`
- `scope_key`
- `fingerprint`
- `recurrence_key`
- `severity`
- `status`
- `first_seen_at`
- `last_seen_at`
- `times_seen`
- `sla_days`
- `due_at`
- `reopened_at`
- `resolved_at`
- `resolved_reason`
- `closed_at`
- `closed_reason`
- `current_operation_run_id`
- `baseline_operation_run_id`
**Feature use**:
- Remains the single persisted source of truth for active findings lifecycle state.
- Continues to require both `workspace_id` and `tenant_id` anchors.
- Keeps the current status families unchanged.
- Carries the lifecycle-ready fields that this feature hardens at write time.
### OperationRun (`operation_runs`)
**Purpose**: Existing execution context for baseline compare and other operational flows.
**Feature use**:
- Remains contextual only.
- `current_operation_run_id` continues to identify the current writer run where the family already sets it.
- No new operation type or new run-tracking artifact is introduced.
### StoredReport (`stored_reports`)
**Purpose**: Existing stored reporting artifact for permission posture output.
**Feature use**:
- Unchanged.
- Mentioned only because permission posture finding generation already correlates lifecycle-ready findings with an existing report artifact.
## Derived Non-Persisted Contracts
### LifecycleReadyFinding (derived contract)
**Definition**: A `Finding` record that is immediately usable by the existing workflow the moment the active writer persists or refreshes it.
**Required fields**:
- active canonical status on first create (`new`)
- `first_seen_at`
- `last_seen_at`
- `times_seen >= 1`
- `sla_days` when the current severity policy returns a value
- `due_at` when the current severity policy requires due-date truth
- existing run correlation fields preserved where the writer already populates them
**Removal rule**:
- no later repair surface may be required for these fields on active writers
### RecurrenceIdentity (derived contract)
**Definition**: The family-owned identity that decides whether a repeated observation refreshes one canonical finding or incorrectly creates a duplicate.
**Family-specific variants**:
- baseline compare: `recurrence_key` and `fingerprint` derived from tenant, baseline profile, policy type, subject key, and change type
- Entra admin roles: existing role-assignment and aggregate fingerprints
- permission posture: existing permission and error fingerprints
**Guarantee**:
- repeated observation of the same canonical issue reuses one finding identity
### ObservationBoundary (derived contract)
**Definition**: The family-specific rule that decides whether `times_seen` should advance.
**Family-specific variants**:
- baseline compare: same `current_operation_run_id` must not increment `times_seen` twice for the same observation
- Entra admin roles: later `observedAt` advances seen history
- permission posture: later `observedAt` advances seen history
**Guarantee**:
- retries and repeated processing do not double count the same observation
## State Transitions Reused
### Create
- missing canonical finding identity -> create one `Finding`
- resulting state remains `new`
- lifecycle-ready fields are populated in the same write path
### Refresh Existing Open Finding
- existing open finding remains in its current active workflow state
- evidence or severity may refresh according to the writer family
- missing lifecycle-ready fields covered by this feature are repaired inline
- valid existing lifecycle fields should not be needlessly reset
### Reopen Existing Terminal Finding
- existing terminal finding transitions through `FindingWorkflowService::reopenBySystem()`
- resulting state becomes `reopened`
- `resolved_*` and `closed_*` markers clear according to the current service behavior
- SLA and due-state truth are recalculated from the later re-observation moment
## Invariant Rules
- No new persisted entity, table, or compatibility artifact may be introduced.
- No new workflow status, reopen reason family, or lifecycle label may be introduced.
- Active writers must repair incomplete lifecycle-ready fields inline rather than relying on CLI repair commands, tenant maintenance actions, or deploy-time hooks.
- Due-state repair should fill missing truth or refresh terminal-to-reopened truth only; it must not silently redesign current due-date semantics for already-healthy open findings.
- A later database constraint is a separate follow-up candidate only if application-level write-path enforcement proves insufficient.

View File

@ -1,295 +0,0 @@
# Implementation Plan: Enforce Creation-Time Finding Invariants
**Branch**: `255-enforce-finding-creation-invariants` | **Date**: 2026-04-29 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/spec.md`
**Note**: This plan is prep-only. It updates only spec-package artifacts for implementation readiness and does not change application code, runtime behavior, migrations, assets, or repo files outside this spec directory.
## Summary
- Make lifecycle-ready finding creation and recurrence semantics explicit across the only three active finding writers currently persisting `Finding` records: baseline compare drift, Entra admin roles, and permission posture.
- Keep the slice narrow and repo-grounded: reuse existing `Finding` fields, existing recurrence identities, existing `FindingWorkflowService::reopenBySystem()`, and existing `FindingSlaPolicy` behavior; do not add repair tooling, workflow states, migrations, or a broader findings framework.
- Tighten validation where repo proof is already strongest: extend the three focused feature suites so they explicitly cover new creation, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active write paths.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing `FindingWorkflowService`, `FindingSlaPolicy`, baseline compare job, Entra admin roles finding generator, and permission posture finding generator
**Storage**: PostgreSQL existing `findings`, `operation_runs`, `stored_reports`, and `audit_logs` only; no new persistence or migration is planned
**Testing**: Pest feature tests in the existing generator and compare suites
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Sail-backed Laravel web application with tenant `/admin/t/{tenant}` findings surfaces and existing background jobs or services that already generate findings
**Project Type**: web
**Performance Goals**: lifecycle invariants must be satisfied in the same write path that creates or refreshes the finding; no second-pass repair job, no extra operator step, and no widened query surface should be required
**Constraints**: LEAN-001 replacement over compatibility shims; no new persistence; no new workflow states; no compare refresh or repair-tooling scope; preserve existing `404` vs `403` behavior; no new Filament assets, panel work, or provider registration changes
**Scale/Scope**: 3 active finding writer families, 1 shared workflow service, 1 shared SLA policy, 1 existing `Finding` model, and 3 established feature-test families plus downstream findings surfaces as regression consumers only
## Likely Affected Repo Surfaces
- Active write paths and their local recurrence or observation logic:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
- Shared lifecycle and due-date seams already reused by those paths:
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- `apps/platform/app/Services/Findings/FindingSlaPolicy.php`
- `apps/platform/app/Models/Finding.php`
- Downstream operator-facing regression consumers that should not need design changes but do rely on `due_at`, `reopened_at`, and canonical open-status truth:
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ListFindings.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
- `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`
- Current focused proof surfaces that already cover part of the invariant and should remain the primary validation entry points:
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
## Domain / Model Fit
- `Finding` remains the single tenant-owned source of truth with required `workspace_id` and `tenant_id` anchors. No new entity, table, compatibility projection, or lifecycle wrapper is introduced.
- The slice does not change the canonical findings status set. `new`, `triaged`, `in_progress`, and `reopened` remain the active statuses; `resolved`, `closed`, and `risk_accepted` remain terminal statuses.
- Lifecycle-ready creation in this feature means that the first persisted or inline-repaired record is already safe for existing downstream workflow use: canonical active status, `first_seen_at`, `last_seen_at`, `times_seen >= 1`, and existing SLA or `due_at` truth when the current severity policy requires them.
- Recurrence identity stays family-owned and explicit rather than being normalized into a new shared engine:
- baseline compare uses `recurrence_key` plus `fingerprint`, with `current_operation_run_id` preventing double counting for the same compare run
- Entra admin roles uses its existing role-assignment and aggregate fingerprints
- permission posture uses its existing missing-permission and error fingerprints
- `OperationRun` and `StoredReport` remain contextual references only where current writers already use them. This slice does not introduce a new audit artifact or independent lifecycle store.
## UI / Filament & Livewire Fit
- No operator-facing surface change is planned. Existing findings resource, inbox, and intake surfaces are regression consumers of better write-time truth, not redesign targets.
- Filament remains v5 on Livewire v4.0+; no Livewire v3 behavior or API is in scope.
- `FindingResource` already has a view page, so the hard global-search rule remains satisfied without new work. No new globally searchable resource is added.
- No destructive action is introduced or changed. Any touched findings action surface must keep current server-side authorization and existing `->requiresConfirmation()` behavior where destructive-like actions already exist.
- No panel/provider work is planned. If provider registration ever became relevant later, Laravel 12 and Filament v5 still require panel providers under `apps/platform/bootstrap/providers.php`, not `bootstrap/app.php`.
- No asset change is planned. Deployment keeps the existing `cd apps/platform && php artisan filament:assets` expectation unchanged only for already-registered assets.
## RBAC / Policy Fit
- This slice should not add a new capability, new role mapping, or new policy branch. User-triggered actions that lead to in-scope finding writes keep their current authorization semantics.
- Tenant membership and workspace membership remain the isolation boundary: non-members stay `404`, in-scope members missing the current capability stay `403`, and no new write bypass is introduced for background or queued generation.
- If implementation appears to require a new capability or policy relaxation just to enforce lifecycle invariants, that is a stop condition and should be split rather than absorbed.
## Audit / Logging Fit
- `FindingWorkflowService::reopenBySystem()` remains the authoritative reopen path because it already owns reopened state mutation, audit context, and alert notification dispatch.
- No new `AuditActionId`, no new operation type, and no new completion notification path should be introduced.
- The feature should preserve existing `current_operation_run_id` and `StoredReport` correlation meaning where current writers already set them. Creation-time hardening must not create a second audit or run-tracking dialect.
## Data / Migration / Constraint Fit
- No migration, no historical data backfill, no deploy hook, and no repair command are planned.
- Under LEAN-001, stale local data or incomplete fixtures should be handled by fixture replacement or inline repair on active write paths, not by compatibility shims.
- A database-level constraint discussion is allowed only as an explicit follow-up or stop condition if planning or implementation proves that application-level write-path enforcement cannot satisfy the invariant safely. It must not be silently folded into this slice.
- If due-date initialization for already-open findings would require recomputing correct existing data instead of filling missing lifecycle fields only, stop and split rather than broadening this feature into a data repair rollout.
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change
- **Native vs custom classification summary**: N/A - existing native Filament findings surfaces remain regression consumers only
- **Shared-family relevance**: none; no new notification, header action, dashboard, or evidence viewer family is added
- **State layers in scope**: none
- **Audience modes in scope**: N/A
- **Decision/diagnostic/raw hierarchy plan**: N/A
- **Raw/support gating plan**: N/A
- **One-primary-action / duplicate-truth control**: existing findings workflow actions remain unchanged; tighter write-time truth prevents partial lifecycle data from competing with the existing canonical action flow
- **Handling modes by drift class or surface**: N/A
- **Repository-signal treatment**: review-mandatory for downstream regression only
- **Special surface test profiles**: standard-native-filament regression only
- **Required tests or manual smoke**: functional-core, state-contract
- **Exception path and spread control**: none
- **Active feature PR close-out entry**: Guardrail
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: no
- **Systems touched**: N/A for shared operator interaction families; domain reuse stays within existing findings lifecycle services only
- **Shared abstractions reused**: existing `FindingWorkflowService` and `FindingSlaPolicy` only
- **New abstraction introduced? why?**: none by default; if a shared write-time normalizer is later proposed, it must be a narrow findings-domain replacement for duplicated inline repair across all three concrete writers, not a new registry or framework
- **Why the existing abstraction was sufficient or insufficient**: `reopenBySystem()` is already sufficient for terminal-to-reopened transitions. The current planning gap is open-record lifecycle repair, which is still duplicated and partially covered across the three writers.
- **Bounded deviation / spread control**: none; keep any repair logic either local to each writer or in one bounded findings-domain helper only if it replaces real duplication immediately
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: N/A
- **Delegated UX behaviors**: N/A
- **Surface-owned behavior kept local**: existing baseline compare and other generation flows keep their current start and completion UX unchanged
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: N/A
- **Platform-core seams**: existing tenant-owned findings truth only
- **Neutral platform terms / contracts preserved**: existing `Finding` lifecycle and tenant/workspace ownership vocabulary remain unchanged
- **Retained provider-specific semantics and why**: provider-specific recurrence evidence stays inside the existing writer families that already own it
- **Bounded extraction or follow-up path**: N/A
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- LEAN-001: PASS - the slice is explicitly app-code hardening only; no compatibility shim, legacy alias, fallback reader, or migration path is planned.
- TEST-GOV-001: PASS - proof stays in the narrowest existing feature suites, with no browser lane and no new heavy-governance family.
- RBAC-UX: PASS - no new capability or policy branch is introduced; non-members remain `404`, members lacking the current capability remain `403`, and system generation stays tenant-scoped.
- PERSIST-001: PASS - no new persisted truth, table, artifact, or projection is introduced.
- STATE-001: PASS - no new state, reason-code family, or lifecycle branch is added; current findings states remain authoritative.
- PROP-001 / ABSTR-001: PASS - the narrowest plan is to align the three concrete write paths and reuse the existing reopen service. Any helper beyond that is a stop-and-justify decision, not a default.
- XCUT-001 / UI-SEM-001: PASS - no new operator interaction family or presentation framework is introduced.
- Filament v5 / Livewire v4 compliance: PASS - existing findings surfaces stay on native Filament v5 with Livewire v4.0+; no legacy API mixing is planned.
- Global-search hard rule: PASS - `FindingResource` already has a view page, and no new searchable resource is added.
- Panel/provider registration: PASS - no panel/provider work is planned; if needed later, Filament v5 on Laravel 12 still uses `apps/platform/bootstrap/providers.php`.
- Destructive confirmation standard: PASS - no new destructive action is added; existing destructive-like actions remain outside this slice.
- Asset strategy: PASS - no new panel or shared asset registration is planned; existing deploy behavior for `filament:assets` remains unchanged.
- Auditability and tenant isolation: PASS - reopen semantics remain on the current audited service path, and every in-scope write remains bound to tenant and workspace context.
## Test Governance Check
- **Test purpose / classification by changed surface**: Feature for writer-level creation-time lifecycle readiness, shared recurrence/workflow-service behavior, and narrow downstream consumer plus trigger-authorization continuity checks; no new unit, browser, or heavy-governance family is planned
- **Affected validation lanes**: fast-feedback, confidence
- **Why this lane mix is the narrowest sufficient proof**: the feature risk lives in domain write behavior already exercised through the existing compare and generator suites, but FR-255-005, FR-255-006, FR-255-009, and FR-255-011 also require bounded proof of shared recurrence/workflow behavior and unchanged consumer/auth continuity. Focused feature coverage is still sufficient because the adjacent checks stay limited to existing findings and trigger-authorization suites.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
- **Fixture / helper / factory / seed / context cost risks**: low to moderate but bounded; reuse existing tenant, operation-run, snapshot, generator, and trigger-surface fixtures. Avoid a new umbrella findings harness unless repeated setup clearly becomes the bottleneck.
- **Expensive defaults or shared helper growth introduced?**: no; the plan explicitly avoids a new generic invariant framework or new default-heavy helper layer.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard-native relief; no browser smoke is required because no operator-facing interaction changes are planned
- **Closing validation and reviewer handoff**: rerun the three writer suites plus the bounded recurrence/workflow and consumer/auth suites, confirm each family now proves missing-field inline repair in addition to existing create/idempotence/reopen behavior, and verify that no migration, no policy branch, and no new UI action was introduced while hardening write paths.
- **Budget / baseline / trend follow-up**: none expected beyond routine feature-test maintenance
- **Review-stop questions**: did implementation widen into a repair tool, migration, DB constraint rollout, or generic invariant framework; did it silently reset already-valid due dates; did it leave one writer family with only partial invariant proof
- **Escalation path**: reject-or-split
- **Active feature PR close-out entry**: Guardrail
- **Why no dedicated follow-up spec is needed**: routine lifecycle-hardening proof belongs in this feature unless a database-level constraint or a broader findings lifecycle redesign is proven necessary later
## Rollout & Risk Controls
- Rollout is code-only and bounded. No migration, queue worker sequencing, asset build, or provider registration step is expected.
- Recommended implementation order is:
1. confirm the shared invariant vocabulary and stop conditions against the three active writers only
2. harden baseline compare first because it already carries the strictest observation-boundary rule through `current_operation_run_id`
3. align permission posture and Entra admin roles creation and refresh logic around the same lifecycle-ready contract while preserving their family-specific recurrence rules
4. extract a shared normalizer only if the concrete code shows immediate duplication across all three paths and the helper replaces duplication instead of adding a new abstraction layer
5. extend focused regression tests and verify downstream findings surfaces do not require design changes
- Stop conditions for task execution:
- another shipped finding writer is discovered outside the three confirmed paths
- the invariant cannot be enforced safely without a migration or DB constraint
- the only available code shape is a new generic registry, strategy system, or lifecycle framework
- user-facing findings workflow affordances would need to change to compensate for missing write-time truth
## Project Structure
### Documentation (this feature)
```text
specs/255-enforce-finding-creation-invariants/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── checklists/
│ └── requirements.md
├── contracts/
│ └── finding-creation-invariants.contract.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Jobs/
│ │ └── CompareBaselineToTenantJob.php
│ ├── Models/
│ │ └── Finding.php
│ ├── Services/
│ │ ├── EntraAdminRoles/
│ │ │ └── EntraAdminRolesFindingGenerator.php
│ │ ├── Findings/
│ │ │ ├── FindingSlaPolicy.php
│ │ │ └── FindingWorkflowService.php
│ │ └── PermissionPosture/
│ │ └── PermissionPostureFindingGenerator.php
│ └── Filament/
│ ├── Pages/Findings/
│ │ ├── FindingsIntakeQueue.php
│ │ └── MyFindingsInbox.php
│ └── Resources/
│ ├── FindingResource.php
│ └── FindingResource/
│ └── Pages/ListFindings.php
└── tests/
└── Feature/
├── Baselines/BaselineCompareFindingsTest.php
├── EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
├── Findings/FindingRecurrenceTest.php
├── Findings/FindingAutomationWorkflowTest.php
├── Findings/FindingWorkflowServiceTest.php
├── Findings/MyWorkInboxTest.php
├── Findings/FindingsIntakeQueueTest.php
├── Rbac/BaselineCompareMatrixAuthorizationTest.php
├── EntraAdminRoles/AdminRolesSummaryWidgetTest.php
└── PermissionPosture/PermissionPostureFindingGeneratorTest.php
```
**Structure Decision**: Laravel monolith. The implementation should stay inside the existing finding writer services and job, the shared findings lifecycle service and model, and the current focused feature suites rather than creating a new namespace or framework.
## Complexity Tracking
No constitution violation is expected. If implementation later proposes a new persistence rule, a new lifecycle framework, or a broad helper layer that serves only speculative future writers, stop and split rather than justifying it inside this slice.
## Proportionality Review
N/A - this feature introduces no new enum, presenter, persisted entity, interface, registry, or taxonomy. Any narrow helper extracted during implementation must replace existing duplicated write-time lifecycle normalization immediately across the three confirmed writers or it should not be introduced.
## Phase 0 — Research (output: `research.md`)
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/research.md`
Goals:
- confirm that the three already-named write paths are still the full active finding-writer inventory in app code
- confirm where current code already repairs lifecycle fields inline and where `sla_days` or `due_at` normalization is still only implied on create or reopen
- document the narrowest shared seam decision: keep repair logic local per writer unless one bounded findings-domain helper clearly replaces real duplication across all three cases
- record the explicit stop condition for any database-level constraint or migration-based enforcement proposal
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/data-model.md`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml`
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/255-enforce-finding-creation-invariants/quickstart.md`
Design focus:
- capture one lifecycle-ready finding contract that all three active writers must satisfy without introducing a new persistence or workflow layer
- keep recurrence identity family-owned while making the create, refresh, and reopen guarantees explicit in one planning contract
- keep downstream Filament findings surfaces, inboxes, and intake queues as regression consumers only; no UI redesign is part of this slice
- document the no-migration, no-constraint-by-default posture and the explicit stop condition for any future constraint follow-up
## Phase 1 — Agent Context Update
- Deferred in this prep-only pass because the user explicitly limited edits to this spec directory.
- If maintainers later want full Spec Kit propagation outside the spec package, run:
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
## Phase 2 — Implementation Outline (tasks created later in `/speckit.tasks`)
- keep the feature bounded to the three confirmed writer paths and the shared reopen service
- align creation-time lifecycle initialization and open-record inline repair in `CompareBaselineToTenantJob`, `EntraAdminRolesFindingGenerator`, and `PermissionPostureFindingGenerator`
- preserve family-specific recurrence and observation-boundary behavior while making it explicit in code and tests
- preserve `FindingWorkflowService::reopenBySystem()` as the only reopened-state mutation path
- extend the three focused feature suites so each family proves creation readiness, repeated observation, resolved-to-reopened behavior, and inline repair of incomplete lifecycle fields encountered on active paths
- verify that no migration, no new capability, no new workflow state, no repair surface, and no operator-facing workflow expansion slipped into the implementation slice
## Constitution Check (Post-Design)
Re-check target: PASS. The post-design shape remains prep-only, introduces no new persistence or state family, keeps Filament on Livewire v4.0+, leaves provider registration unchanged in `apps/platform/bootstrap/providers.php`, keeps global search unchanged through the existing `FindingResource` view page, leaves destructive actions untouched, and keeps the proving burden inside the three existing focused feature suites unless a bounded stop condition forces a split.
- **Ownership cost created**: focused ongoing maintenance in the three writer suites plus bounded shared recurrence/workflow and trigger-authorization regressions; no migration, framework, or new persistence cost is added.
- **Alternative intentionally rejected**: a generic invariant framework, a new repair or backfill path, and any DB-constraint rollout were rejected because the repo currently has three concrete writers and current-release truth only requires tightening those exact paths.
- **Release truth**: current-release truth. This package hardens already-shipped finding writers rather than preparing speculative future families.

View File

@ -1,39 +0,0 @@
# Quickstart — Enforce Creation-Time Finding Invariants
## Prereqs
- Docker running
- Laravel Sail dependencies installed
- Existing compare and generator feature fixtures available
- Existing tenant/workspace helpers available for targeted findings tests
## Run locally after implementation
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
- Use the normal repo baseline before running tests: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan migrate --no-interaction`
- Run the focused validation suites for this slice:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
- Format any implementation changes: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
The two additional commands are the only bounded adjacent proof beyond the three writer suites. They cover shared recurrence/workflow semantics plus unchanged downstream consumer and trigger-authorization contracts.
## Manual smoke after implementation
1. Trigger one baseline compare drift finding and confirm the newly created record appears immediately usable on `/admin/t/{tenant}/findings`, including due-state and seen-history cues where current UI already renders them.
2. Trigger one permission posture and one Entra admin roles finding and confirm the first persisted record has the expected lifecycle-ready fields without any maintenance action.
3. Resolve an in-scope finding, re-observe the same issue, and confirm the same finding identity reopens with refreshed due or SLA truth and existing history retained.
4. Re-run the same baseline compare operation identity and confirm `times_seen` does not double count on retry.
5. Review the diff and confirm no file under `apps/platform/database/migrations/` changed and no new repair surface, capability, or operator-facing workflow branch was introduced.
## Notes
- Filament v5 remains on Livewire v4.0+ in this repo; this feature does not add or redesign an operator-facing Filament surface.
- `FindingResource` already has a view page, so there is no new global-search compliance work.
- No new destructive action is planned; existing destructive-like findings actions stay outside this slice and keep their current confirmation and authorization behavior.
- No panel or provider change is planned; `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location for Filament work in Laravel 12.
- No asset change is expected, so there is no additional `filament:assets` deployment work for this slice.
- This prep package intentionally leaves repo-wide agent-context regeneration outside scope so changes stay inside `specs/255-enforce-finding-creation-invariants/` only.

View File

@ -1,126 +0,0 @@
# Research — Enforce Creation-Time Finding Invariants
**Date**: 2026-04-29
**Spec**: [spec.md](spec.md)
This document records the repo-grounded planning decisions for the creation-time findings hardening slice after Specs 253 and 254. All decisions assume the current pre-production LEAN-001 posture.
## Decision 1 — Scope the feature to the three active finding writers that currently persist `Finding` records
**Decision**: Treat baseline compare drift, Entra admin roles, and permission posture as the full active writer set for this feature.
**Rationale**:
- Repo search shows only five direct `Finding` creation sites in app code: one `new Finding` path in `CompareBaselineToTenantJob` and four `Finding::create()` sites split between Entra admin roles and permission posture.
- No other shipped service or job currently persists `Finding` records directly, so widening the slice would be speculative rather than repo-driven.
- This keeps the hardening aligned with the spec's stated bounded scope and avoids inventing a new writer registry.
**Evidence**:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
**Alternatives considered**:
- Widen to every findings consumer or downstream summary surface.
- Rejected: those surfaces consume findings truth but do not create it.
- Add a speculative "all writers" registry now.
- Rejected: violates ABSTR-001 because three concrete paths are already directly visible.
## Decision 2 — Enforce lifecycle readiness in the same write path, not through a later repair pass
**Decision**: Require each in-scope writer to create or refresh lifecycle-ready findings inside the same code path that persists or updates the record.
**Rationale**:
- Spec 253 removes runtime backfill surfaces and this feature explicitly exists to prevent reintroducing that repair dependency.
- Current code already initializes lifecycle fields on new creates and updates some fields inline on repeated observations; that makes write-path hardening the narrowest correct implementation.
- Downstream findings pages, inboxes, and intake queues already assume findings are ready for immediate use.
**Evidence**:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Filament/Resources/FindingResource.php`
- `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`
**Alternatives considered**:
- Reintroduce a maintenance action or backfill command.
- Rejected: directly conflicts with the cleanup direction from Spec 253.
- Add a deploy-time or queue-time repair hook.
- Rejected: widens scope and hides invariant ownership.
## Decision 3 — Preserve `FindingWorkflowService::reopenBySystem()` as the only shared reopen path
**Decision**: Keep terminal-to-reopened mutation on `FindingWorkflowService::reopenBySystem()` and treat open-record lifecycle normalization as the actual planning gap.
**Rationale**:
- `reopenBySystem()` already validates terminal-status eligibility, recalculates SLA or due state, clears resolved or closed markers, writes audit context, and dispatches the reopened alert notification.
- Bypassing it would create a second reopen dialect and risk inconsistent audit or notification semantics.
- The repo gap is not reopened-state ownership; it is that current open-record repair is still distributed across per-family `observeFinding()` logic and currently emphasizes seen-history more than full lifecycle readiness.
**Evidence**:
- `apps/platform/app/Services/Findings/FindingWorkflowService.php`
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
**Alternatives considered**:
- Reopen findings directly inside each writer.
- Rejected: duplicates side effects and weakens audit consistency.
- Create a new generic lifecycle orchestration framework.
- Rejected: too broad for three known writers.
## Decision 4 — Keep recurrence identity family-owned and preserve each writer's current double-count boundary
**Decision**: Keep the existing recurrence identity and observation boundary per family instead of forcing one synthetic cross-domain algorithm.
**Rationale**:
- Baseline compare already uses `recurrence_key` plus `fingerprint` with `current_operation_run_id` to suppress duplicate `times_seen` increments for the same compare run.
- Entra admin roles and permission posture use later `observedAt` comparisons to advance seen history.
- The operator need is one canonical finding identity per issue family, not one universal recurrence engine.
**Evidence**:
- `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
**Alternatives considered**:
- Normalize all writers onto a single recurrence service.
- Rejected: would add abstraction without current-release need.
- Count every repeated observation the same way across all writers.
- Rejected: risks breaking baseline retry semantics.
## Decision 5 — The current proof gap is inline repair of incomplete lifecycle fields on existing findings
**Decision**: Plan for explicit regression proof that existing open findings with missing lifecycle fields are repaired inline on active paths, especially for `sla_days` and `due_at`.
**Rationale**:
- Existing tests already prove creation readiness, idempotence, and reopen behavior in all three families.
- Repo code also already repairs `first_seen_at`, `last_seen_at`, and `times_seen` inline when existing findings are re-observed.
- What is not yet clearly owned as one invariant is the repair of incomplete lifecycle fields such as missing due-state data on existing findings encountered through active writers.
**Evidence**:
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
**Alternatives considered**:
- Rely on current creation and reopen tests only.
- Rejected: leaves FR-255-007 partially implied.
- Add a new browser or broad workflow suite.
- Rejected: too expensive for a write-path invariant gap.
## Decision 6 — Keep schema and DB constraints out of the slice unless they become an explicit stop condition
**Decision**: Keep the default plan app-code-only. Any database-level constraint or migration-based enforcement is a bounded follow-up candidate or an explicit stop condition, not part of this feature by default.
**Rationale**:
- The repo is pre-production and LEAN-001 favors direct replacement over compatibility layers.
- The current code already has the necessary domain seams to harden write-time behavior without changing the schema.
- Folding a constraint into this feature would silently broaden it from write-path hardening into data rollout and compatibility review.
**Evidence**:
- `.specify/memory/constitution.md`
- `specs/255-enforce-finding-creation-invariants/spec.md`
**Alternatives considered**:
- Add `NOT NULL` or check constraints now.
- Rejected: outside the smallest bounded slice.
- Keep the option undefined.
- Rejected: the plan must name the stop condition explicitly so task generation stays bounded.

View File

@ -1,280 +0,0 @@
# Feature Specification: Enforce Creation-Time Finding Invariants
**Feature Branch**: `255-enforce-finding-creation-invariants`
**Created**: 2026-04-29
**Status**: Draft
**Input**: User description: "Prepare only the spec artifacts for `Enforce Creation-Time Finding Invariants` on the existing 255 branch as the next bounded findings data-integrity slice after Specs 253 and 254."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: Findings that reach operators through active generators already tend to look lifecycle-ready, but that truth is still implied and distributed. If a new or changed generator path omits status, seen timestamps, seen count, or due/SLA initialization, operators can receive findings that look real before they are workflow-ready.
- **Today's failure**: TenantPilot would have to rely on scattered implicit behavior or future repair logic to make a new or recurring finding usable. That weakens trust in due state, recurrence history, and reopen behavior right at the moment an operator is asked to act.
- **User-visible improvement**: Newly created or reopened findings arrive already ready for existing workflow use, with stable identity and lifecycle metadata that operators can trust immediately.
- **Smallest enterprise-capable version**: Make creation-time and recurrence-time finding invariants explicit for the active generator families and their shared reopen semantics, backed by focused regression proof, while reusing existing finding fields and workflow states only.
- **Explicit non-goals**: No backfill runtime surfaces, no acknowledged cleanup, no new customer-facing workflow, no broader findings lifecycle redesign, no new persistence, no new states, no external integration, no owner/assignee mandate, and no schema rollout except a possible future narrow follow-up.
- **Permanent complexity imported**: Low and bounded. The feature should add only explicit invariant coverage and possibly a narrow shared write-time guard if planning proves it necessary; no new table, state family, framework, or operator surface is justified.
- **Why now**: Specs 253 and 254 remove adjacent repair and compatibility debt. The next bounded unspecced findings candidate is to lock in the post-cleanup target state so active generators cannot drift back into repair-tool dependency.
- **Why not local**: Repo truth spans baseline compare, Entra admin roles, permission posture, shared reopen behavior, SLA/due initialization, and recurrence semantics. A local fix in one generator would leave the others as implied behavior and keep the invariant unowned.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Multiple micro-specs for one domain. Defense: this is the final bounded data-integrity hardening slice after surface removal and acknowledged cleanup, and it explicitly avoids bundling broader lifecycle redesign or new infrastructure.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 2 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Selection Rationale
- The source candidate is the active P1 entry `Enforce Creation-Time Finding Invariants` in `docs/product/spec-candidates.md`.
- This slice sits in the Findings Workflow / Data Integrity sequence and follows Spec 253 (`Remove Findings Lifecycle Backfill Runtime Surfaces`) and Spec 254 (`Remove Legacy Acknowledged Finding Status Compatibility`).
- It is the next bounded unspecced candidate in repo order. Customer Review Workspace, Decision-Based Governance Inbox, Commercial Entitlements and Billing-State Maturity, Platform Localization, Remove Findings Lifecycle Backfill Runtime Surfaces, and Remove Legacy Acknowledged Finding Status Compatibility already have specs.
- `External Support Desk / PSA Handoff` remains blocked because the repo still does not name one concrete external desk or PSA target.
- `Cross-Tenant Compare and Promotion v1` already has Spec 043 and is a refresh candidate, not the next unspecced preparation target.
- The smallest viable slice is to prove that active finding generators and reopen/recurrence paths always create or refresh findings in a lifecycle-ready state at write time, without reintroducing repair tooling, redesigning the lifecycle, adding new persistence, adding new workflow states, or widening into external workflow work.
## Spec Scope Fields *(mandatory)*
- **Scope**: tenant
- **Primary Routes**:
- No new or changed direct route is the product target.
- Existing tenant findings surfaces are downstream regression consumers only: `/admin/t/{tenant}/findings` and `/admin/t/{tenant}/findings/{record}`.
- In-scope behavior is reached through existing tenant-scoped finding generation paths, including baseline compare completion, Entra admin role finding generation, and permission posture finding generation.
- **Data Ownership**:
- Tenant-owned `Finding` records remain the canonical truth and keep required `workspace_id` plus `tenant_id` anchors.
- Existing `OperationRun` and `StoredReport` references stay contextual only where the current generators already use them; this feature introduces no new persisted entity, mirror table, or compatibility artifact.
- The scope is limited to write-time creation and refresh behavior for existing finding truth.
- **RBAC**:
- Tenant membership remains the isolation boundary for the downstream findings surfaces that consume these records.
- Existing user-triggered paths that lead to in-scope finding creation remain capability-first; non-members stay 404 and members lacking the current capability stay 403.
- Background or system-triggered generation must preserve tenant/workspace isolation and must not create a bypass that can write findings outside the current tenant scope.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: no
- **Interaction class(es)**: N/A - no shared operator interaction family is added or changed
- **Systems touched**: N/A - operator-facing shared interaction patterns stay unchanged
- **Existing pattern(s) to extend**: none
- **Shared contract / presenter / builder / renderer to reuse**: none
- **Why the existing shared path is sufficient or insufficient**: This slice hardens tenant-owned finding writes and shared lifecycle semantics, not notifications, action surfaces, or dashboard presentation.
- **Allowed deviation and why**: none
- **Consistency impact**: downstream findings and review surfaces continue consuming the same finding truth without any new UI branch
- **Review focus**: reviewers should verify that the feature stays in write-time lifecycle hardening and does not smuggle in new operator interaction patterns
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: N/A
- **Delegated start/completion UX behaviors**: N/A
- **Local surface-owned behavior that remains**: existing baseline compare and other current generation flows keep their current launch, completion, and link UX; this slice only hardens the finding writes they already produce
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: no
- **Boundary classification**: N/A
- **Seams affected**: N/A
- **Neutral platform terms preserved or introduced**: N/A
- **Provider-specific semantics retained and why**: N/A
- **Why this does not deepen provider coupling accidentally**: The slice hardens existing tenant-owned finding lifecycle truth across already-active generators without introducing a new shared provider seam, taxonomy, or vocabulary.
- **Follow-up path**: none
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
N/A - no operator-facing surface change. Existing findings, review, and summary surfaces are regression consumers of better write-time truth, not redesign targets in this feature.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators should not receive a newly created or reopened finding that still depends on an implicit later repair step before due state, seen history, or canonical workflow use is trustworthy.
- **Existing structure is insufficient because**: the write-time invariant exists only as distributed repo behavior today. It is partially proven in separate tests, but not yet owned as one explicit product hardening slice across the active generator families and their recurrence semantics.
- **Narrowest correct implementation**: make the invariant explicit across the verified active generator families and shared reopen/recurrence behavior, using the existing finding fields, existing workflow states, existing SLA policy, and focused regression proof only.
- **Ownership cost**: a small amount of enduring regression coverage and possibly a narrow shared write-time guard if planning proves it necessary. No new table, state family, or general framework is justified.
- **Alternative intentionally rejected**: reintroducing lifecycle backfill or repair tooling, adding a new invariant framework or persistence layer, or widening into a broader findings lifecycle redesign.
- **Release truth**: current-release truth
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: the behavioral proof stays centered on the three focused writer suites around baseline compare, Entra admin roles, and permission posture, with only bounded adjacent regression in shared recurrence/workflow-service and downstream consumer/auth continuity tests because FR-255-005, FR-255-006, FR-255-009, and FR-255-011 cross the writer boundaries.
- **New or expanded test families**: none by default; reuse and tighten the three focused writer suites, plus bounded regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` only where they prove shared recurrence, consumer honesty, or unchanged trigger authorization
- **Fixture / helper cost impact**: low and near-neutral. The default path should reuse existing tenant, finding, and operation helpers instead of adding a broader harness.
- **Heavy-family visibility / justification**: none. No new heavy-governance or browser family is justified for this slice.
- **Special surface test profile**: N/A
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient because this slice hardens domain truth behind existing workflows rather than adding a new UI surface
- **Reviewer handoff**: reviewers must confirm that the final proof covers new finding creation, repeated observation, resolved-to-reopened transitions, unchanged 404 versus 403 semantics on the existing trigger surfaces, and preserved `current_operation_run_id` meaning without expanding into unrelated workflow or UI coverage
- **Budget / baseline / trend impact**: none expected beyond ordinary focused feature-test upkeep
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
## RBAC / Isolation Considerations
- Tenant-owned findings remain scoped by `workspace_id` and `tenant_id`. The feature must not create or preserve tenantless finding truth.
- Existing user-triggered operations that can lead to the in-scope finding writes keep current capability-first authorization. This slice does not add a new capability or role alias.
- Downstream findings and review surfaces keep current deny-as-not-found versus forbidden behavior: non-members remain 404, in-scope members missing the existing capability remain 403 on triggering actions.
- Explicit 404 versus 403 continuity proof stays bounded to `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php` and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`, because permission posture finding generation is background-triggered rather than launched from a separate tenant UI action.
- System-initiated reopen or refresh behavior stays inside the current tenant/workspace context and must not widen read or write visibility across tenants.
## Auditability
- Existing workflow-driven reopen semantics remain authoritative for system reopen behavior. The feature must preserve current audit and workflow meaning instead of introducing a silent side path.
- Existing `current_operation_run_id` correlations stay in place where the current generators already populate them; this slice does not add a second run-correlation path or new audit artifact.
- The hardening must not allow partially initialized findings to look settled or complete on downstream operator surfaces. The audit trail should continue to explain system-created and system-reopened findings through the existing lifecycle paths.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Ready Findings Immediately (Priority: P1)
As a tenant operator, I want a newly detected finding to arrive already ready for the existing findings workflow so I can trust the status, due state, and seen history the first time it appears.
**Why this priority**: This is the core product-truth outcome. If new findings still depend on implied repair logic, the feature has failed.
**Independent Test**: Can be fully tested by triggering one new finding in each in-scope generator family and verifying that the first persisted record is already lifecycle-ready before any downstream findings page or review surface consumes it.
**Acceptance Scenarios**:
1. **Given** a baseline compare run detects new drift for a tenant, **When** the finding is first written, **Then** it already carries the canonical open status, first seen and last seen timestamps, seen count, and the due or SLA data required by the existing workflow.
2. **Given** an Entra admin roles or permission posture run detects a new issue, **When** the tenant findings register later displays that record, **Then** no backfill, repair action, or second pass is required to make the finding usable in the existing workflow.
---
### User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1)
As a tenant operator, I want a resolved issue that reappears to reopen the same finding with fresh lifecycle truth so I can continue work with the existing history instead of receiving a duplicate or stale record.
**Why this priority**: Reopen behavior is the critical recurrence path that keeps findings trustworthy after the cleanup sequence in Specs 253 and 254.
**Independent Test**: Can be fully tested by resolving an in-scope finding, observing the same issue again, and verifying that the existing finding reopens with refreshed lifecycle fields.
**Acceptance Scenarios**:
1. **Given** a previously resolved baseline drift finding reappears, **When** the same drift is observed again, **Then** the existing finding reopens, resolved markers clear as needed, and the lifecycle fields required for current workflow use are refreshed at write time.
2. **Given** a previously resolved Entra admin roles or permission posture finding reappears, **When** the generator sees the same active issue again, **Then** the system reopens the same finding identity and refreshes the due or SLA truth according to the current severity policy already in use.
---
### User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2)
As a tenant operator, I want repeated detection of the same active issue to strengthen the same finding record instead of creating uncontrolled duplicates or inflating seen counts incorrectly.
**Why this priority**: Stable recurrence semantics protect operator trust in counts, history, and due attention without widening the feature into broader lifecycle redesign.
**Independent Test**: Can be fully tested by retrying or repeating the same observation across the in-scope families and verifying one canonical finding identity with bounded seen-count updates.
**Acceptance Scenarios**:
1. **Given** the same canonical issue is retried under the same observation identity, **When** the generator processes it again, **Then** the system does not create a duplicate finding and does not double-count the same observation.
2. **Given** the same canonical issue is observed again under a later valid observation, **When** the generator refreshes the existing finding, **Then** the same finding identity remains in place and the seen history advances according to that family's existing recurrence semantics.
### Edge Cases
- A retried baseline compare job using the same run identity must not increment `times_seen` twice for the same observation.
- An existing finding encountered on a normal active path may still be missing `first_seen_at`, `last_seen_at`, or `times_seen`; the in-scope write path must repair those fields inline instead of depending on a separate repair surface.
- A resolved finding should reopen only when the new observation is later than the prior resolution boundary; out-of-order or stale observations must not incorrectly reopen it.
- If current SLA policy derives a due date from severity, the reopened or newly created record must be ready for that downstream truth immediately; the feature must not defer due-state initialization to a later process.
- The feature must preserve one canonical finding identity even when evidence payloads or current hashes change between observations.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes tenant-owned finding write behavior but does not add Microsoft Graph calls, a new user-facing mutation surface, or a new long-running workflow. It hardens existing write-time semantics in current generator paths, preserves tenant isolation, preserves existing audit meaning plus `current_operation_run_id` correlation on the in-scope write paths, and requires focused regression proof.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice must not introduce new persistence, new abstraction, new state family, or new semantic layer. If planning proposes a shared invariant helper, it must prove why the existing distributed write paths cannot safely stay explicit without creating a new unowned drift point.
**Constitution alignment (XCUT-001):** No new cross-cutting operator interaction family is allowed in this slice. Existing findings, review, and summary surfaces remain unchanged consumers of better write-time truth.
**Constitution alignment (TEST-GOV-001):** Proof stays in the narrow focused feature tests already closest to the active generator families and their recurrence behavior. The feature must not create a new heavy family or browser dependency.
**Constitution alignment (OPS-UX / OPS-UX-START-001):** Existing baseline compare and other current generation flows may continue using their current `OperationRun` semantics where already present, but this feature does not add or change operation start UX, queued notification policy, or deep-link behavior.
**Constitution alignment (RBAC-UX):** Existing triggering authorization stays capability-first and unchanged. The feature must not add a hidden bypass or new capability branch to create or reopen findings.
**Constitution alignment (OPSURF-001 / DECIDE-AUD-001):** Existing operator surfaces must never depend on partially initialized finding truth. The hardening exists so downstream decision surfaces continue to show calm, honest workflow data without false readiness.
### Functional Requirements
- **FR-255-001**: The system MUST ensure each in-scope active finding generator family writes a newly created finding in a lifecycle-ready state within the same write path that first persists the record.
- **FR-255-002**: The in-scope active generator families for this feature are baseline compare drift, Entra admin roles, and permission posture. The invariant MUST be explicit across all three families, not only one local path.
- **FR-255-003**: A newly created in-scope finding MUST carry the canonical initial workflow status plus the lifecycle fields needed by existing downstream workflow surfaces, including first seen and last seen timestamps, a valid seen count, and existing SLA or due-date truth when the current severity policy already requires them.
- **FR-255-004**: Repeated observation of the same active condition MUST reuse one canonical finding identity through the existing recurrence key or fingerprint semantics and MUST refresh the existing record instead of creating uncontrolled canonical duplicates.
- **FR-255-005**: A retry or repeated processing of the same observation identity MUST NOT double-count the same observation. Each in-scope generator family may keep its current observation semantics, but the feature MUST make those semantics explicit and regression-protected.
- **FR-255-006**: When a previously resolved in-scope finding reappears, the system MUST reopen the existing finding through the current workflow path and MUST clear or refresh the lifecycle data required for immediate downstream workflow use.
- **FR-255-007**: If an in-scope active path encounters an existing finding with missing lifecycle fields covered by this slice, the normal write path MUST repair those fields inline instead of depending on backfill jobs, tenant repair actions, CLI repair commands, or deploy-time hooks.
- **FR-255-008**: The feature MUST preserve current tenant/workspace isolation by keeping every in-scope finding write anchored to the current tenant and workspace and by not widening visibility or write scope across tenants.
- **FR-255-009**: The feature MUST preserve capability-first RBAC and existing 404 versus 403 semantics on the current user-triggered entry points that lead to in-scope finding creation or refresh, specifically the baseline compare matrix and admin-roles scan surfaces.
- **FR-255-010**: The feature MUST preserve existing finding workflow states, downstream review surfaces, and operator affordances. It MUST NOT add new workflow states, reintroduce repair tooling, re-open acknowledged-status cleanup, require owner or assignee fields, or add external support or PSA workflow scope.
- **FR-255-011**: The feature MUST keep existing audit meaning and `current_operation_run_id` correlation intact where the current generators already attach reopened or refreshed findings to system workflow paths.
- **FR-255-012**: Regression proof MUST make the invariant explicit across new creation, repeated observation, and resolved-to-reopened behavior for the in-scope generator families.
- **FR-255-013**: Any database constraint or migration-based invariant enforcement beyond the existing application write paths is out of scope for this feature and MAY only be considered as a later narrow follow-up if planning proves it is compatibility-safe and materially smaller than a broader redesign.
### Key Entities *(include if feature involves data)*
- **Lifecycle-ready finding**: A tenant-owned finding record that is immediately usable in the existing workflow because it already has canonical lifecycle status, seen history, recurrence identity, and due/SLA truth where current policy requires it.
- **Finding generator family**: One of the active repo-owned write paths that creates or refreshes findings today: baseline compare drift, Entra admin roles, or permission posture.
- **Recurrence identity**: The existing recurrence key or fingerprint semantics that decide whether repeated observation refreshes one finding or incorrectly creates a new one.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: During regression validation, 100% of newly created in-scope findings arrive with the lifecycle data needed by the existing downstream workflow in the same observation cycle that first persists them.
- **SC-002**: During regression validation, 0 in-scope active finding paths require a separate repair, backfill, or deploy-time step before newly created or reopened findings are safe to show on existing workflow surfaces.
- **SC-003**: During regression validation, repeated observation of the same in-scope issue reuses one canonical finding identity instead of creating uncontrolled duplicates across each in-scope generator family.
- **SC-004**: During regression validation, previously resolved in-scope findings reopen through the existing workflow path with refreshed lifecycle truth across each in-scope generator family.
## Dependencies
- The baseline compare finding creation and recurrence path in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- The Entra admin roles finding creation and reopen path in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- The permission posture finding creation and reopen path in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
- Existing shared finding lifecycle behavior such as reopen semantics and SLA/due calculation already used by those paths
- Existing focused regression proof in:
- `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
## Assumptions
- Spec 253 removes the visible lifecycle backfill runtime surfaces and Spec 254 removes acknowledged compatibility first, so this slice can focus only on the post-cleanup target state.
- The three verified active generator families above are the full bounded scope for this feature unless planning finds another currently active finding writer that is equally first-class and already shipping.
- Lifecycle-ready does not make owner, assignee, or additional governance fields mandatory. It only covers the existing lifecycle truth needed for current workflow readiness.
- The product remains pre-production, so historical data migration, compatibility shims, and retained repair tooling are not justified.
- Downstream findings, review, and summary surfaces should continue working without design changes if write-time truth is hardened correctly.
## Risks
- Another active finding writer may exist outside the three verified families and remain unsafely implicit if planning does not confirm the full set before implementation.
- Over-eager implementation could introduce a generic invariant framework or broaden into lifecycle redesign, which would violate the intended slice boundary.
- Different generator families already count repeated observation differently; forcing one artificial rule instead of preserving each family's valid observation semantics could create regressions while trying to harden the invariant.
## Out of Scope
- Reintroducing findings lifecycle backfill runtime surfaces, repair commands, deploy hooks, or tenant repair actions
- Removing acknowledged compatibility or changing broader findings workflow vocabulary, which is already covered by Spec 254
- New customer-facing workflow surfaces, review inbox redesign, customer review workspace work, or localization work
- New persistence, new workflow states, new owner/assignee requirements, or broader findings lifecycle redesign
- External Support Desk / PSA Handoff work
- Cross-Tenant Compare and Promotion refresh work already tracked under Spec 043
- Schema changes, migrations, or database constraints except as an explicit later follow-up candidate
## Follow-up Candidates
1. A very narrow database-level invariant guard may be considered later only if planning proves it can enforce one of these fields safely without reopening compatibility or widening the feature.
2. `External Support Desk / PSA Handoff` remains deferred until the repo names one concrete external desk or PSA target.
3. `Cross-Tenant Compare and Promotion v1` remains on the existing Spec 043 track as a refresh candidate rather than being reopened inside this hardening slice.

View File

@ -1,242 +0,0 @@
# Tasks: Enforce Creation-Time Finding Invariants
**Input**: Design documents from `/specs/255-enforce-finding-creation-invariants/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/finding-creation-invariants.contract.yaml`, `checklists/requirements.md`
**Tests (TEST-GOV-001)**: REQUIRED (Pest). Keep proof in the targeted `fast-feedback` and `confidence` lanes named in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md`. Keep the three writer suites as the primary proof in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`. Use only bounded adjacent regression in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php` where they prove shared recurrence, consumer honesty, or unchanged trigger authorization without inflating the implementation scope into direct UI rewrites.
**Operations**: This slice does not add or change an `OperationRun` start family, does not add a new audit action ID, and must not introduce migrations, DB constraints, repair tooling, deploy hooks, or external integrations. Existing `current_operation_run_id` correlations stay contextual only where the current writers already set them, and any report-emission assertions remain bounded to the writer suites that already own them.
**RBAC**: Preserve current tenant/workspace isolation, current `404` versus `403` behavior on the baseline compare matrix and admin-roles scan trigger surfaces, and the existing tenant-scoped background/system reopen semantics. Do not add a new capability, bypass, or customer-facing workflow branch.
**UI / Surface Guardrails**: This is a `review-mandatory` write-time truth hardening slice with `standard-native-filament` relief. `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, and `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php` stay regression consumers only unless existing tests prove a shared-truth fix is insufficient.
**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, panel, provider, or asset work is introduced. `FindingResource` already has a view page, so global-search compliance stays satisfied without new tasking. No new destructive action is introduced or changed.
**Organization**: Tasks are grouped by user story so each slice stays independently verifiable. Recommended delivery order is Phase 1 -> Phase 2 -> `US1` -> `US2` -> `US3` -> final validation, because creation-readiness must be explicit before reopen and recurrence proofs are tightened.
**Implementation note**: If creation-time invariants converge through the three writer paths plus `FindingWorkflowService` and `FindingSlaPolicy`, keep downstream findings surfaces untouched and make proof responsibility explicit in their existing test files rather than planning direct edits to every listed consumer file.
## Test Governance Checklist
- [x] Lane assignment stays `fast-feedback` plus `confidence` and remains the narrowest sufficient proof for write-time lifecycle hardening.
- [x] New or changed tests stay in focused `Feature` files only; no browser or new heavy-governance family is added.
- [x] Shared helpers, factories, fixtures, and context defaults stay cheap by default; any broader setup is isolated to the findings suites that already need it.
- [x] Planned validation commands stay limited to the quickstart command set, allowing the three writer-suite commands to be combined into one equivalent Sail invocation plus the shared recurrence, consumer, and trigger-authorization checks below.
- [x] The declared surface test profile stays `standard-native-filament`; downstream findings surfaces remain proof consumers only.
- [x] Any material residue or follow-up note resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`, not as implicit scope drift.
## Phase 1: Setup (Shared Invariant Anchors)
**Purpose**: Lock the bounded writer inventory, shared lifecycle seams, and proving commands before implementation starts.
- [x] T001 [P] Verify the bounded feature package, stop conditions, and non-goals across `specs/255-enforce-finding-creation-invariants/spec.md`, `specs/255-enforce-finding-creation-invariants/plan.md`, `specs/255-enforce-finding-creation-invariants/research.md`, `specs/255-enforce-finding-creation-invariants/data-model.md`, `specs/255-enforce-finding-creation-invariants/quickstart.md`, and `specs/255-enforce-finding-creation-invariants/contracts/finding-creation-invariants.contract.yaml`
- [x] T002 [P] Verify the active finding-writer and shared seam inventory across `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and `apps/platform/app/Models/Finding.php`
- [x] T003 [P] Verify the narrow Sail validation commands and manual smoke expectations in `specs/255-enforce-finding-creation-invariants/plan.md` and `specs/255-enforce-finding-creation-invariants/quickstart.md`
- [x] T004 [P] Verify downstream proof-only consumers across `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, and `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`
**Checkpoint**: The bounded invariant target, shared seams, and validation entry points are explicit before any runtime file changes begin.
---
## Phase 2: Foundational (Blocking Proof Surfaces)
**Purpose**: Make the intended proof surfaces and adjacent cleanup guardrails explicit before the write paths are changed.
**CRITICAL**: No user story work should begin until this phase is complete.
- [x] T005 [P] Lock the per-family lifecycle-ready and inline-repair proof plan in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- [x] T006 [P] Lock the shared recurrence and reopen proof plan in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php`, `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
- [x] T007 [P] Audit incomplete-lifecycle fixture and helper anchors across `apps/platform/database/factories/FindingFactory.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, and `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- [x] T008 [P] Audit adjacent cleanup guardrails in `apps/platform/tests/Feature/Findings/RemoveFindingsLifecycleBackfillActionTest.php` and `apps/platform/tests/Feature/Findings/RemoveAcknowledgedCompatibilityWorkflowTest.php` so this slice does not reintroduce repair tooling or acknowledged compatibility
**Checkpoint**: Writer-level proof, shared reopen proof, and adjacent no-regression guardrails are explicit and ready for bounded implementation work.
---
## Phase 3: User Story 1 - See Ready Findings Immediately (Priority: P1)
**Goal**: Newly detected findings arrive lifecycle-ready on first persistence across the three active writer families.
**Independent Test**: Trigger one new finding per writer family and verify the first persisted record already carries canonical open status, seen history, ownership anchors, and due or SLA truth without any repair or second-pass workflow.
### Tests for User Story 1
- [x] T009 [P] [US1] Extend baseline compare create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [x] T010 [P] [US1] Extend Entra admin roles create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- [x] T011 [P] [US1] Extend permission posture create-readiness and incomplete-field inline-repair coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
### Implementation for User Story 1
- [x] T012 [US1] Align baseline compare finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [x] T013 [US1] Align Entra admin roles finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`
- [x] T014 [US1] Align permission posture finding creation and active-record refresh with the lifecycle-ready contract in `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
- [x] T015 [US1] Keep ownership anchors plus due or SLA initialization explicit without introducing a migration or repair surface in `apps/platform/app/Models/Finding.php`, `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, and the three story tests from `T009` through `T011`
**Checkpoint**: User Story 1 is independently functional and all three active writers create lifecycle-ready findings in the same write path.
---
## Phase 4: User Story 2 - Reopen the Same Finding When the Risk Returns (Priority: P1)
**Goal**: Resolved findings reopen through the existing shared workflow path with refreshed lifecycle truth and preserved canonical identity.
**Independent Test**: Resolve an in-scope finding, re-observe the same issue through each writer family, and verify the same record reopens with cleared terminal markers and refreshed due or SLA truth.
### Tests for User Story 2
- [x] T016 [P] [US2] Add baseline compare resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`
- [x] T017 [P] [US2] Add Entra admin roles resolved-to-reopened regression and `current_operation_run_id` continuity coverage in `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`
- [x] T018 [P] [US2] Add permission posture resolved-to-reopened regression plus existing `current_operation_run_id` and stored-report emission continuity coverage in `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- [x] T019 [P] [US2] Tighten shared reopen-service proof for `reopenBySystem()` due or SLA recalculation, audit continuity, and terminal eligibility in `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
### Implementation for User Story 2
- [x] T020 [US2] Keep reopened-state mutation on `FindingWorkflowService::reopenBySystem()` and reconcile baseline compare call sites in `apps/platform/app/Services/Findings/FindingWorkflowService.php` and `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`
- [x] T021 [US2] Preserve same-finding reopen identity and family-specific evidence refresh in `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php` and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
- [x] T022 [US2] Reconcile reopened due-date, SLA, and resolved-marker expectations without adding new workflow states or audit dialects in `apps/platform/app/Services/Findings/FindingSlaPolicy.php`, `apps/platform/app/Services/Findings/FindingWorkflowService.php`, and `apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php`
**Checkpoint**: User Story 2 is independently functional and resolved findings reopen through the existing shared workflow semantics rather than duplicating records or adding a second reopen path.
---
## Phase 5: User Story 3 - Keep One Canonical Finding Identity Through Repeated Detection (Priority: P2)
**Goal**: Repeated observation strengthens the same finding record, respects each family's observation boundary, and keeps downstream surfaces truthful without widening the feature into UI redesign.
**Independent Test**: Re-run the same observation and then a later valid observation across the in-scope families and verify that one canonical finding identity remains in place, same-observation retries do not double count, and downstream findings surfaces still read honest lifecycle truth.
### Tests for User Story 3
- [x] T023 [P] [US3] Extend same-observation idempotence and canonical-identity reuse coverage in `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php`
- [x] T024 [P] [US3] Extend cross-family recurrence and observation-boundary coverage in `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` and `apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php`
- [x] T025 [P] [US3] Tighten downstream consumer and trigger-authorization proof that shared lifecycle truth still renders honestly and that non-members remain `404` while in-scope capability failures remain `403` in `apps/platform/tests/Feature/Findings/MyWorkInboxTest.php`, `apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
### Implementation for User Story 3
- [x] T026 [US3] Preserve family-owned recurrence keys, fingerprints, and observation boundaries while preventing duplicate canonical findings in `apps/platform/app/Jobs/CompareBaselineToTenantJob.php`, `apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php`, and `apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`
- [x] T027 [US3] Keep any shared lifecycle normalization bounded to `apps/platform/app/Services/Findings/` only when it replaces real duplication across all three writers, with proof confined to `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php`, `apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`, and `apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php` rather than widening into new workflow or UI files
**Checkpoint**: User Story 3 is independently functional and recurrence keeps one canonical finding identity without double-counting or forcing direct downstream surface rewrites.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Keep the slice bounded, run the narrow validation workflow, and check for out-of-scope residue.
- [x] T028 [P] Run formatting for touched PHP files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [x] T029 [P] Run the focused writer-suite Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php`
- [x] T030 [P] Run the focused shared-recurrence Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingRecurrenceTest.php tests/Feature/Findings/FindingAutomationWorkflowTest.php tests/Feature/Findings/FindingWorkflowServiceTest.php`
- [x] T031 [P] Run the downstream-consumer and trigger-RBAC Sail command `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/MyWorkInboxTest.php tests/Feature/Findings/FindingsIntakeQueueTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/EntraAdminRoles/AdminRolesSummaryWidgetTest.php`
- [ ] T032 [P] Execute quickstart manual smoke steps 1 through 4 from `specs/255-enforce-finding-creation-invariants/quickstart.md` against `/admin/t/{tenant}/findings`, `MyFindingsInbox`, and `FindingsIntakeQueue`, then leave diff/scope review to `T034`
- [x] T033 [P] Run residue searches for `backfill`, `repair`, `constraint`, `migration`, and any new `Finding::STATUS_` additions across `apps/platform/app/`, `apps/platform/tests/`, `apps/platform/database/`, and `specs/255-enforce-finding-creation-invariants/`, then classify each remaining match as allowed shared-consumer proof, in-scope cleanup to delete now, or `reject-or-split`
- [x] T034 Verify that no file under `apps/platform/database/migrations/` changed, no new repair or rollout entry point appeared under `apps/platform/app/Console/Commands/` or `apps/platform/app/Services/Runbooks/`, and no direct workflow expansion landed in `apps/platform/app/Filament/Resources/FindingResource.php`, `apps/platform/app/Filament/Pages/Findings/MyFindingsInbox.php`, or `apps/platform/app/Filament/Pages/Findings/FindingsIntakeQueue.php`; if truth-consumer proof from `T025` and `T031` suggests a direct UI edit is necessary, stop and record that as `document-in-feature` or `reject-or-split` instead of treating it as default in-scope work
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and locks the exact scope, writer inventory, and proving commands.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until proof files, fixtures, and adjacent cleanup guardrails are explicit.
- **User Story 1 (Phase 3)**: Depends on Foundational and establishes the lifecycle-ready create contract.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because reopen semantics should refresh the same lifecycle-ready contract established at creation time.
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because recurrence and consumer proof only mean the right thing after create and reopen behavior are aligned.
- **Polish (Phase 6)**: Depends on all desired user stories being complete so final validation and residue checks run on the finished slice.
### User Story Dependencies
- **US1**: No dependencies beyond Foundational.
- **US2**: Depends on US1.
- **US3**: Depends on US1 and US2.
### Within Each User Story
- Add or update the story tests first and confirm they fail before implementation edits are considered complete.
- Keep recurrence identity family-owned instead of introducing a generic invariant framework.
- Keep downstream findings surfaces as proof consumers unless shared-truth tests prove a concrete need for direct edits.
- Keep migrations, DB constraints, repair tooling, acknowledged cleanup, external support-desk or PSA work, customer-facing workflow changes, and broad findings redesign out of scope.
### Parallel Opportunities
- `T001`, `T002`, `T003`, and `T004` can run in parallel during Setup.
- `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work.
- `T009`, `T010`, and `T011` can run in parallel for User Story 1 before `T012`, `T013`, `T014`, and `T015`.
- `T016`, `T017`, `T018`, and `T019` can run in parallel for User Story 2 before `T020`, `T021`, and `T022`.
- `T023`, `T024`, and `T025` can run in parallel for User Story 3 before `T026` and `T027`.
- `T029`, `T030`, `T031`, `T032`, and `T033` can run in parallel during final validation after `T028`, followed by `T034` as the final scope-boundary check.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T009 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php
T010 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
T011 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php
# User Story 1 implementation after the tests are in place
T012 apps/platform/app/Jobs/CompareBaselineToTenantJob.php
T013 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php
T014 apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T016 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php
T017 apps/platform/tests/Feature/EntraAdminRoles/EntraAdminRolesFindingGeneratorTest.php
T018 apps/platform/tests/Feature/PermissionPosture/PermissionPostureFindingGeneratorTest.php
T019 apps/platform/tests/Feature/Findings/FindingWorkflowServiceTest.php
# User Story 2 implementation after the tests are in place
T020 apps/platform/app/Services/Findings/FindingWorkflowService.php + apps/platform/app/Jobs/CompareBaselineToTenantJob.php
T021 apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T023 apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php + apps/platform/tests/Feature/Baselines/BaselineCompareFindingRecurrenceKeyTest.php
T024 apps/platform/tests/Feature/Findings/FindingRecurrenceTest.php + apps/platform/tests/Feature/Findings/FindingAutomationWorkflowTest.php
T025 apps/platform/tests/Feature/Findings/MyWorkInboxTest.php + apps/platform/tests/Feature/Findings/FindingsIntakeQueueTest.php
# User Story 3 implementation after the tests are in place
T026 apps/platform/app/Jobs/CompareBaselineToTenantJob.php + apps/platform/app/Services/EntraAdminRoles/EntraAdminRolesFindingGenerator.php + apps/platform/app/Services/PermissionPosture/PermissionPostureFindingGenerator.php
T027 apps/platform/app/Services/Findings/
```
---
## Implementation Strategy
### MVP First (User Stories 1 and 2)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Complete Phase 4: User Story 2.
5. Run `T028`, `T029`, and `T030` before widening into recurrence and consumer-proof cleanup.
### Incremental Delivery
1. Lock the bounded writer inventory, proof files, and stop conditions.
2. Make create-time lifecycle readiness explicit for baseline compare, Entra admin roles, and permission posture.
3. Preserve the shared reopen path and refresh lifecycle truth when resolved findings return.
4. Tighten recurrence, idempotence, and downstream proof without widening into UI redesign or repair tooling.
5. Finish with focused Sail validation, manual smoke, and residue checks.
### Parallel Team Strategy
1. One contributor can own the three writer-family tests while another confirms shared recurrence and downstream consumer proof after Phase 2.
2. After User Story 1 lands, one contributor can align the reopen path while another prepares the recurrence and consumer proof for User Story 3.
3. Finish with one bounded pass for formatting, focused Sail validation, and residue or scope-boundary review.
---
## Notes
- Suggested MVP scope: Phase 1 through Phase 4. Creation readiness without reopen reuse is not sufficient for this feature.
- Explicit non-goals remain: runtime backfill surfaces, acknowledged cleanup, new workflow states, broad findings redesign, migrations or DB constraints, repair tooling, external support-desk or PSA work, and customer-facing workflow expansion.
- Filament remains on Livewire v4.0+; no panel/provider or asset strategy changes are needed, and `apps/platform/bootstrap/providers.php` remains the relevant provider-registration location if later Filament work is ever required.
- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and concrete file paths.

View File

@ -0,0 +1,228 @@
# Feature 005: Policy Lifecycle Management
## Overview
Implement proper lifecycle management for policies that are deleted in Intune, including soft delete, UI indicators, and orphaned policy handling.
## Problem Statement
Currently, when a policy is deleted in Intune:
- ❌ Policy remains in TenantAtlas database indefinitely
- ❌ No indication that policy no longer exists in Intune
- ❌ Backup Items reference "ghost" policies
- ❌ Users cannot distinguish between active and deleted policies
**Discovered during**: Feature 004 manual testing (user deleted policy in Intune)
## Goals
- **Primary**: Implement soft delete for policies removed from Intune
- **Secondary**: Show clear UI indicators for deleted policies
- **Tertiary**: Maintain referential integrity for Backup Items and Policy Versions
## Scope
- **Policy Sync**: Detect missing policies during `SyncPoliciesJob`
- **Data Model**: Add `deleted_at`, `deleted_by` columns (Laravel Soft Delete pattern)
- **UI**: Badge indicators, filters, restore capability
- **Audit**: Log when policies are soft-deleted and restored
---
## User Stories
### User Story 1 - Automatic Soft Delete on Sync
**As a system administrator**, I want policies deleted in Intune to be automatically marked as deleted in TenantAtlas, so that the inventory reflects the current Intune state.
**Acceptance Criteria:**
1. **Given** a policy exists in TenantAtlas with `external_id` "abc-123",
**When** the next policy sync runs and "abc-123" is NOT returned by Graph API,
**Then** the policy is soft-deleted (sets `deleted_at = now()`)
2. **Given** a soft-deleted policy,
**When** it re-appears in Intune (same `external_id`),
**Then** the policy is automatically restored (`deleted_at = null`)
3. **Given** multiple policies are deleted in Intune,
**When** sync runs,
**Then** all missing policies are soft-deleted in a single transaction
---
### User Story 2 - UI Indicators for Deleted Policies
**As an admin**, I want to see clear indicators when viewing deleted policies, so I understand their status.
**Acceptance Criteria:**
1. **Given** I view a Backup Item referencing a deleted policy,
**When** I see the policy name,
**Then** it shows a red "Deleted" badge next to the name
2. **Given** I view the Policies list,
**When** I enable the "Show Deleted" filter,
**Then** deleted policies appear with:
- Red "Deleted" badge
- Deleted date in "Last Synced" column
- Grayed-out appearance
3. **Given** a policy was deleted,
**When** I view the Policy detail page,
**Then** I see:
- Warning banner: "This policy was deleted from Intune on {date}"
- All data remains readable (versions, snapshots, metadata)
---
### User Story 3 - Restore Workflow
**As an admin**, I want to restore a deleted policy from backup, so I can recover accidentally deleted configurations.
**Acceptance Criteria:**
1. **Given** I view a deleted policy's detail page,
**When** I click the "Restore to Intune" action,
**Then** the restore wizard opens pre-filled with the latest policy snapshot
2. **Given** a policy is successfully restored to Intune,
**When** the next sync runs,
**Then** the policy is automatically undeleted in TenantAtlas (`deleted_at = null`)
---
## Functional Requirements
### Data Model
**FR-005.1**: Policies table MUST use Laravel Soft Delete pattern:
```php
Schema::table('policies', function (Blueprint $table) {
$table->softDeletes(); // deleted_at
$table->string('deleted_by')->nullable(); // admin email who triggered deletion
});
```
**FR-005.2**: Policy model MUST use `SoftDeletes` trait:
```php
use Illuminate\Database\Eloquent\SoftDeletes;
class Policy extends Model {
use SoftDeletes;
}
```
### Policy Sync Behavior
**FR-005.3**: `PolicySyncService::syncPolicies()` MUST detect missing policies:
- Collect all `external_id` values returned by Graph API
- Query existing policies for this tenant: `whereNotIn('external_id', $currentExternalIds)`
- Soft delete missing policies: `each(fn($p) => $p->delete())`
**FR-005.4**: System MUST restore policies that re-appear:
- Check if policy exists with `Policy::withTrashed()->where('external_id', $id)->first()`
- If soft-deleted: call `$policy->restore()`
- Update `last_synced_at` timestamp
**FR-005.5**: System MUST log audit entries:
- `policy.deleted` (when soft-deleted during sync)
- `policy.restored` (when re-appears in Intune)
### UI Display
**FR-005.6**: PolicyResource table MUST:
- Default query: exclude soft-deleted policies
- Add filter "Show Deleted" (includes `withTrashed()` in query)
- Show "Deleted" badge for soft-deleted policies
**FR-005.7**: BackupItemsRelationManager MUST:
- Show "Deleted" badge when `policy->trashed()` returns true
- Allow viewing deleted policy details (read-only)
**FR-005.8**: Policy detail view MUST:
- Show warning banner when policy is soft-deleted
- Display deletion date and reason (if available)
- Disable edit actions (policy no longer exists in Intune)
---
## Non-Functional Requirements
**NFR-005.1**: Soft delete MUST NOT break existing features:
- Backup Items keep valid foreign keys
- Policy Versions remain accessible
- Restore functionality works for deleted policies
**NFR-005.2**: Performance: Sync detection MUST NOT cause N+1 queries:
- Use single `whereNotIn()` query to find missing policies
- Batch soft-delete operation
**NFR-005.3**: Data retention: Soft-deleted policies MUST be retained for audit purposes (no automatic purging)
---
## Implementation Plan
### Phase 1: Data Model (30 min)
1. Create migration for `policies` soft delete columns
2. Add `SoftDeletes` trait to Policy model
3. Run migration on dev environment
### Phase 2: Sync Logic (1 hour)
1. Update `PolicySyncService::syncPolicies()`
- Track current external IDs from Graph
- Soft delete missing policies
- Restore re-appeared policies
2. Add audit logging
3. Test with manual deletion in Intune
### Phase 3: UI Indicators (1.5 hours)
1. Update `PolicyResource`:
- Add "Show Deleted" filter
- Add "Deleted" badge column
- Modify query to exclude deleted by default
2. Update `BackupItemsRelationManager`:
- Show "Deleted" badge for `policy->trashed()`
3. Update Policy detail view:
- Warning banner for deleted policies
- Disable edit actions
### Phase 4: Testing (1 hour)
1. Unit tests:
- Test soft delete on sync
- Test restore on re-appearance
2. Feature tests:
- E2E sync with deleted policies
- UI filter behavior
3. Manual QA:
- Delete policy in Intune → sync → verify soft delete
- Re-create policy → sync → verify restore
**Total Estimated Duration**: ~4-5 hours
---
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Foreign key constraints block soft delete | Laravel soft delete only sets timestamp, constraints remain valid |
| Bulk delete impacts performance | Use chunked queries if tenant has 1000+ policies |
| Deleted policies clutter UI | Default filter hides them, "Show Deleted" is opt-in |
---
## Success Criteria
1. ✅ Policies deleted in Intune are soft-deleted in TenantAtlas within 1 sync cycle
2. ✅ Re-appearing policies are automatically restored
3. ✅ UI clearly indicates deleted status
4. ✅ Backup Items and Versions remain accessible for deleted policies
5. ✅ No breaking changes to existing features
---
## Related Features
- Feature 004: Assignments & Scope Tags (discovered this issue during testing)
- Feature 001: Backup/Restore (must work with deleted policies)
---
**Status**: Planned (Post-Feature 004)
**Priority**: P2 (Quality of Life improvement)
**Created**: 2025-12-22
**Author**: AI + Ahmed
**Next Steps**: Implement after Feature 004 Phase 3 testing complete