Automatische PR: 255-enforce-finding-creation-invariants → platform-dev #298

Merged
ahmido merged 1 commits from 255-enforce-finding-creation-invariants into platform-dev 2026-04-29 12:26:22 +00:00
14 changed files with 1524 additions and 15 deletions
Showing only changes of commit d7e2291867 - Show all commits

View File

@ -1871,8 +1871,11 @@ 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,
); );
} }
@ -1947,12 +1950,21 @@ private function upsertFindings(
]; ];
} }
private function observeFinding(Finding $finding, CarbonImmutable $observedAt, int $currentOperationRunId): void private function observeFinding(
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;
} }
@ -1964,6 +1976,14 @@ private function observeFinding(Finding $finding, CarbonImmutable $observedAt, i
} 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

@ -163,7 +163,7 @@ private function upsertFinding(
->first(); ->first();
if ($existing instanceof Finding) { if ($existing instanceof Finding) {
$this->observeFinding($existing, $observedAt); $this->observeFinding($existing, $tenant, $observedAt, $severity);
$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, $observedAt); $this->observeFinding($existing, $tenant, $observedAt, Finding::SEVERITY_HIGH);
$existing->forceFill([ $existing->forceFill([
'severity' => Finding::SEVERITY_HIGH, 'severity' => Finding::SEVERITY_HIGH,
@ -380,24 +380,32 @@ private function resolveSlaPolicy(): FindingSlaPolicy
return $this->slaPolicy ?? app(FindingSlaPolicy::class); return $this->slaPolicy ?? app(FindingSlaPolicy::class);
} }
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): 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; $finding->times_seen = 1;
} }
if ($timesSeen < 1) { $slaPolicy = $this->resolveSlaPolicy();
$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

@ -140,7 +140,7 @@ private function handleMissingPermission(
->first(); ->first();
if ($finding instanceof Finding) { if ($finding instanceof Finding) {
$this->observeFinding($finding, $observedAt); $this->observeFinding($finding, $tenant, $observedAt, $severity);
$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, $observedAt); $this->observeFinding($existing, $tenant, $observedAt, $severity);
$existing->forceFill([ $existing->forceFill([
'severity' => $severity, 'severity' => $severity,
@ -349,24 +349,30 @@ private function resolveObservedAt(array $comparison, ?OperationRun $operationRu
return CarbonImmutable::now(); return CarbonImmutable::now();
} }
private function observeFinding(Finding $finding, CarbonImmutable $observedAt): void private function observeFinding(Finding $finding, Tenant $tenant, CarbonImmutable $observedAt, string $severity): 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; $finding->times_seen = 1;
} }
if ($timesSeen < 1) { if ($finding->sla_days === null) {
$finding->times_seen = 1; $finding->sla_days = $this->slaPolicy->daysForSeverity($severity, $tenant);
}
if ($finding->due_at === null) {
$finding->due_at = $this->slaPolicy->dueAtForSeverity($severity, $tenant, $firstSeenAt);
} }
} }

View File

@ -528,6 +528,133 @@
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

@ -195,6 +195,54 @@ 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();

View File

@ -149,6 +149,45 @@ 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

@ -0,0 +1,48 @@
# 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

@ -0,0 +1,101 @@
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

@ -0,0 +1,130 @@
# 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

@ -0,0 +1,295 @@
# 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

@ -0,0 +1,39 @@
# 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

@ -0,0 +1,126 @@
# 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

@ -0,0 +1,280 @@
# 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

@ -0,0 +1,242 @@
# 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.