# Implementation Plan: 116 — Baseline Drift Engine (Final Architecture) **Branch**: `116-baseline-drift-engine` | **Date**: 2026-03-01 | **Spec**: `specs/116-baseline-drift-engine/spec.md` **Input**: Feature specification from `specs/116-baseline-drift-engine/spec.md` ## Summary Align the existing baseline capture/compare pipeline to Spec 116 by (1) defining an explicit meta-fidelity hash contract, (2) enforcing the “coverage guard” based on the latest inventory sync run, and (3) switching baseline-compare findings to snapshot-scoped stable identities (recurrence keys) while preserving existing baseline-profile grouping for UI/stats and auto-close semantics. ## Technical Context **Language/Version**: PHP 8.4 **Primary Dependencies**: Laravel 12, Filament v5, Livewire v4 **Storage**: PostgreSQL (via Sail) **Testing**: Pest v4 (PHPUnit 12) **Target Platform**: Docker (Laravel Sail) **Project Type**: Web application (Laravel) **Performance Goals**: Compare jobs must remain bounded by scope size; avoid N+1 queries when loading snapshot + current inventory **Constraints**: - Ops-UX: OperationRun lifecycle + 3-surface feedback (toast queued-only, progress in widget/run detail, terminal DB notification exactly-once) - Summary counts numeric-only and keys restricted to `OperationSummaryKeys` - Tenant/workspace isolation + RBAC deny-as-not-found rules **Scale/Scope**: Tenant inventories may be large; baseline compare must be efficient on `(tenant_id, policy_type)` filtering ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* - Inventory-first: PASS — compare uses Inventory as last observed state; baselines are immutable snapshots. - Read/write separation: PASS — this feature is read-only analysis; no Graph writes. - Graph contract path: PASS — inventory sync already uses `GraphClientInterface`; baseline compare itself is DB-only at render time. - Deterministic capabilities: PASS — baseline capability checks use existing registries/policies; no new ad-hoc strings. - Workspace + tenant isolation: PASS — baseline profiles are workspace-owned; runs/findings are tenant-owned; authorization remains deny-as-not-found for non-members. - Run observability (OperationRun): PASS — capture/compare already use OperationRun + queued jobs. - Ops-UX 3-surface feedback: PASS — existing pages use canonical queued toast presenter. - Ops-UX lifecycle: PASS — transitions must remain inside `OperationRunService`. - Ops-UX summary counts: PASS — only numeric summary counts using canonical keys. - Filament UI contract: PASS — only small scope-picker adjustments; no new pages beyond what exists. - Filament UX-001 layout: PASS — Baseline Profile Create/Edit will be updated to a Main/Aside layout as part of the scope-picker work. ## Project Structure ### Documentation (this feature) ```text specs/116-baseline-drift-engine/ ├── plan.md ├── research.md ├── data-model.md ├── quickstart.md ├── contracts/ │ └── openapi.yaml └── checklists/ └── requirements.md ``` ### Source Code (repository root) ```text app/ ├── Filament/ │ ├── Pages/ # Baseline compare landing + run detail links (existing) │ ├── Resources/ # BaselineProfileResource (existing) │ └── Widgets/ # Baseline compare widgets (existing) ├── Jobs/ │ ├── CaptureBaselineSnapshotJob.php │ └── CompareBaselineToTenantJob.php ├── Models/ │ ├── BaselineProfile.php │ ├── BaselineSnapshot.php │ ├── BaselineSnapshotItem.php │ ├── Finding.php │ ├── InventoryItem.php │ └── OperationRun.php ├── Services/ │ ├── Baselines/ │ │ ├── BaselineCaptureService.php │ │ ├── BaselineCompareService.php │ │ ├── BaselineAutoCloseService.php │ │ └── BaselineSnapshotIdentity.php │ ├── Drift/ │ │ ├── DriftFindingGenerator.php │ │ └── DriftHasher.php │ ├── Inventory/ │ │ └── InventorySyncService.php │ └── OperationRunService.php └── Support/ ├── Baselines/BaselineCompareStats.php └── OpsUx/OperationSummaryKeys.php tests/ └── Feature/ └── Baselines/ ├── BaselineCompareFindingsTest.php ├── BaselineComparePreconditionsTest.php ├── BaselineCompareStatsTest.php └── BaselineOperabilityAutoCloseTest.php ``` **Structure Decision**: Web application (Laravel 12) — all work stays in existing `app/` services/jobs/models and `tests/Feature`. ## Complexity Tracking No constitution violations are required for this feature. ## Phase 0 — Outline & Research (DONE) Outputs: - `specs/116-baseline-drift-engine/research.md` Key reconciliations captured: - Baseline compare finding identity will move to recurrence-key based upsert (snapshot-scoped identity) aligned with the existing `DriftFindingGenerator` pattern. - Coverage guard requires persisting per-type coverage outcomes into the latest inventory sync run context. - Scope must include `policy_types` + `foundation_types` with correct empty-default semantics. ## Phase 1 — Design & Contracts (DONE) Outputs: - `specs/116-baseline-drift-engine/data-model.md` - `specs/116-baseline-drift-engine/contracts/openapi.yaml` - `specs/116-baseline-drift-engine/quickstart.md` Design highlights: - Coverage lives in `operation_runs.context` for inventory sync runs (detailed lists), while `summary_counts` remain numeric-only. - Findings use `recurrence_key` and `fingerprint = recurrence_key` for idempotent upserts. - Findings remain grouped by `scope_key = baseline_profile:{id}` to preserve existing UI/stats and auto-close behavior. ## Phase 1 — Agent Context Update (REQUIRED) Run: - `.specify/scripts/bash/update-agent-context.sh copilot` ## Phase 2 — Implementation Plan ### Step 1 — Baseline scope schema + UI picker Goal: implement FR-116v1-01 and FR-116v1-02. Changes: - Update baseline scope handling (`app/Support/Baselines/BaselineScope.php`) to support: - `policy_types: []` meaning “all supported policy types excluding foundations” - `foundation_types: []` meaning “none” - Update `BaselineProfile` form schema (Filament Resource) to show multi-selects for Policy Types and Foundations. - Document selector-to-config mapping (source of truth for option lists + defaults): | Selector | Form state path | Options source | Default semantics | |---|---|---|---| | Policy Types | `scope_jsonb.policy_types` | `config('tenantpilot.supported_policy_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::supported()` | Empty ⇒ all supported policy types (**excluding foundations**) | | Foundations | `scope_jsonb.foundation_types` | `config('tenantpilot.foundation_types')` via `App\Support\Inventory\InventoryPolicyTypeMeta::foundations()` | Empty ⇒ none | Notes: - Inventory sync selection uses `App\Services\BackupScheduling\PolicyTypeResolver::supportedPolicyTypes()` for policy types, and `InventorySyncService::foundationTypes()` (derived from `config('tenantpilot.foundation_types')`) when `include_foundations=true`. Tests: - Update/add Pest tests around scope expansion defaults (prefer a focused unit-like test if an expansion helper exists). ### Step 2 — Inventory Meta Contract (explicit hash input) Goal: implement FR-116v1-04, FR-116v1-05, FR-116v1-06, FR-116v1-06a. Changes: - Introduce a dedicated contract builder (e.g. `App\Services\Baselines\InventoryMetaContract`) that returns a normalized array for hashing. - Contract output must be explicitly versioned (e.g., `meta_contract.version = 1`) so future additions do not retroactively change v1 semantics. - Contract signals are best-effort: missing signals are represented as `null` (not omitted) to keep hashing stable across partial inventories. - Update baseline capture hashing (`BaselineSnapshotIdentity::hashItemContent()` or the capture service) to hash the contract output only. - Persist the exact contract payload used for hashing to `baseline_snapshot_items.meta_jsonb.meta_contract` for auditability/reproducibility. - Persist observation metadata alongside the hash in `baseline_snapshot_items.meta_jsonb` (at minimum: `fidelity`, `source`, `observed_at`; when available: `observed_operation_run_id`). - Update baseline compare to compute `current_hash` using the same contract builder. - Current-state `observed_at` is derived from persisted inventory evidence (`inventory_items.last_seen_at`) and MUST NOT require per-item external hydration calls during compare. - Define “latest successful snapshot” (v1) as `baseline_profiles.active_snapshot_id` and ensure compare start is blocked when it is `null` (no “pick newest captured_at” fallback). Tests: - Add a small Pest test for contract normalization stability (ordering, missing fields, nullability) in `tests/Unit/Baselines/InventoryMetaContractTest.php`. - Update baseline capture/compare tests if they currently assume hashing full `meta_jsonb`. ### Step 3 — Inventory sync coverage recording Goal: provide coverage for FR-116v1-07. Changes: - Extend inventory sync pipeline (in `App\Services\Inventory\InventorySyncService` and/or the job that orchestrates sync) to write a coverage payload into the inventory sync `OperationRun.context`: - Per policy type: status (`succeeded|failed|skipped`) and optional `item_count`. - Foundations can be included in the same shape if they are part of selection. - Ensure this is written even when some types fail, so downstream compare can determine uncovered types. Tests: - Add/extend tests around inventory sync operation context writing (mocking Graph calls as needed; keep scope minimal). ### Step 4 — Baseline compare coverage guard + outcome semantics Goal: implement FR-116v1-07 and align to Ops-UX. Changes: - In baseline compare job/service: - Resolve the latest inventory sync run for the tenant. - Compute `covered_policy_types` from sync run context. - Compute `uncovered_policy_types = effective_scope.policy_types - covered_policy_types`. - Skip emission of *all* finding types for uncovered policy types. - Record coverage details into the compare run `context` for auditability. - If uncovered types exist, set compare outcome to `partially_succeeded` via `OperationRunService` and set `summary_counts.errors_recorded = count(uncovered_policy_types)`. - If effective scope expands to zero types, complete as `partially_succeeded` and set `summary_counts.errors_recorded = 1` so the warning remains visible under numeric-only summary counts. - If there is no completed inventory sync run (or coverage proof is missing/unreadable), treat coverage as unproven for all effective-scope types (fail-safe): emit zero findings and complete as `partially_succeeded`. Tests: - Add a new Pest test in `tests/Feature/Baselines` asserting: - uncovered types cause partial outcome - uncovered types produce zero findings (even if snapshot/current data would otherwise create missing/unexpected/different) - covered types still produce findings ### Step 5 — Snapshot-scoped stable finding identity Goal: implement FR-116v1-09 and FR-116v1-10. Changes: - Replace hash-evidence-based `fingerprint` generation in baseline compare with a stable recurrence key: - Inputs: `tenant_id`, `baseline_snapshot_id`, `policy_type`, `subject_external_id`, `change_type` - Persist: - `findings.recurrence_key = ` - `findings.fingerprint = ` - Keep `scope_key = baseline_profile:{baselineProfileId}`. - Ensure retry idempotency: do not increment lifecycle counters more than once per run identity. Tests: - Update `tests/Feature/Baselines/BaselineCompareFindingsTest.php`: - Ensure fingerprint no longer depends on baseline/current hash. - Assert stable identity across re-runs with changed evidence hashes. - Add coverage for “recapture uses new snapshot id → new finding identity”. ### Step 6 — Auto-close + stats compatibility Goal: preserve existing operability expectations and keep UI stable. Changes: - Ensure `BaselineAutoCloseService` still resolves stale findings after a fully successful compare, even though identities now include snapshot id. - Confirm `BaselineCompareStats` remains correct for grouping by `scope_key = baseline_profile:{id}`. Tests: - Update/keep `tests/Feature/Baselines/BaselineOperabilityAutoCloseTest.php` passing. - Update `tests/Feature/Baselines/BaselineCompareStatsTest.php` only if scope semantics change. ### Step 7 — Ops UX + auditability Goal: implement FR-116v1-03 and FR-116v1-11. Changes: - Ensure both capture and compare runs write: - `effective_scope.*` in run context - coverage summary and uncovered lists when partial - numeric summary counts using canonical keys only - per-change-type finding counts in `operation_runs.context.findings.counts_by_change_type` - Treat the `operation_runs` record as the canonical audit trail for this feature slice (do not add parallel “audit summary” persistence for the same data). Tests: - Add a regression test that asserts `summary_counts` contains only allowed keys and numeric values (where a helper exists). ## Post-design Constitution Re-check Expected: PASS (no changes introduce new Graph endpoints or bypass services; OperationRun lifecycle + 3-surface feedback remain intact; RBAC deny-as-not-found semantics preserved).