# 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.