Compare commits

..

4 Commits

Author SHA1 Message Date
2752515da5 Spec 235: harden baseline truth and onboarding flows (#271)
Some checks failed
Main Confidence / confidence (push) Failing after 54s
## Summary
- harden baseline capture truth, compare readiness, and monitoring explanations around latest inventory eligibility, blocked prerequisites, and zero-subject outcomes
- improve onboarding verification and bootstrap recovery handling, including admin-consent callback invalidation and queued execution legitimacy/report behavior
- align workspace findings/workspace overview signals and refresh the related spec, roadmap, and spec-candidate artifacts

## Validation
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/BaselineDriftEngine/BaselineCaptureAuditEventsTest.php tests/Feature/BaselineDriftEngine/BaselineSnapshotNoTenantIdentifiersTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineContentTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineFullContentOnDemandTest.php tests/Feature/BaselineDriftEngine/CaptureBaselineMetaFallbackTest.php tests/Feature/Baselines/BaselineCaptureTest.php tests/Feature/Baselines/BaselineCompareFindingsTest.php tests/Feature/Baselines/BaselineSnapshotBackfillTest.php tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/AdminConsentCallbackTest.php tests/Feature/Filament/WorkspaceOverviewDbOnlyTest.php tests/Feature/Guards/Spec194GovernanceActionSemanticsGuardTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Operations/QueuedExecutionAuditTrailTest.php tests/Unit/Operations/QueuedExecutionLegitimacyGateTest.php`

## Notes
- browser validation was not re-run in this pass

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #271
2026-04-24 05:44:54 +00:00
603d509b8f cleanup: retire dead transitional residue (#270)
Some checks failed
Main Confidence / confidence (push) Failing after 58s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
## Summary
- remove deprecated baseline profile status alias constants and keep baseline lifecycle semantics on the canonical enum path
- retire the dead tenant app-status badge/default-fixture residue from the active runtime support path
- add the `234-dead-transitional-residue` spec, plan, research, data-model, quickstart, checklist, and task artifacts plus focused regression assertions

## Validation
- not rerun during this PR creation step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #270
2026-04-23 16:54:48 +00:00
6fdd45fb02 feat: surface stale active operation runs (#269)
Some checks failed
Main Confidence / confidence (push) Failing after 53s
## Summary
- keep stale active operation runs visible in the tenant progress overlay and polling state
- align tenant and canonical operation surfaces around the shared stale-active presentation contract
- add Spec 233 artifacts and clean the promoted-candidate backlog entries

## Validation
- browser smoke: `/admin/t/18000000-0000-4000-8000-000000000180` -> stale dashboard CTA -> `/admin/operations?tenant_id=7&activeTab=active_stale_attention&problemClass=active_stale_attention` -> `/admin/operations/15`
- verified healthy vs likely-stale tenant cards, canonical stale list row, and canonical run detail consistency

## Notes
- local smoke fixture seeded with one fresh and one stale running `baseline_compare` operation for browser validation
- Pest suite was not re-run in this session before opening this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #269
2026-04-23 15:10:06 +00:00
2bf53f6337 Enforce operation run link contract (#268)
Some checks failed
Main Confidence / confidence (push) Failing after 44s
## Summary
- enforce shared operation run link generation across admin and system surfaces
- add guard coverage to block new raw operation route bypasses outside explicit exceptions
- harden Filament theme asset resolution so stale or wrong-stack hot files fall back to built assets

## Testing
- export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
- export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/InventoryCoverageRunContinuityTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/144/CanonicalOperationViewerDeepLinkTrustTest.php tests/Feature/078/RelatedLinksOnDetailTest.php tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php tests/Feature/System/Spec113/AuthorizationSemanticsTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php tests/Unit/Filament/PanelThemeAssetTest.php

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #268
2026-04-23 13:09:53 +00:00
100 changed files with 7277 additions and 384 deletions

View File

@ -242,6 +242,12 @@ ## Active Technologies
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy) - PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract) - PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract)
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract) - PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks` (233-stale-run-visibility)
- Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence (233-stale-run-visibility)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests (234-dead-transitional-residue)
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -276,9 +282,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers - 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
- 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` - 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) - 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -1,32 +1,28 @@
<!-- <!--
Sync Impact Report Sync Impact Report
- Version change: 2.7.0 -> 2.8.0 - Version change: 2.8.0 -> 2.9.0
- Modified principles: None - Modified principles:
- Added provider-boundary guardrail set under First Provider Is Not
Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
- Expanded Governance review expectations for provider-owned vs
platform-core boundaries
- Added sections: - Added sections:
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases, - First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
migration shims, dual-write logic, and compatibility fixtures in a the current first provider without allowing provider-specific
pre-production codebase; includes AI-agent verification checklist, semantics to silently become platform-core truth; requires explicit
review rule, and explicit exit condition at first production deploy review of provider-owned vs platform-core seams and prefers bounded
- Shared Pattern First For Cross-Cutting Interaction Classes extraction over speculative multi-provider frameworks
(XCUT-001): requires shared contracts/presenters/builders for
notifications, status messaging, action links, dashboard signals,
navigation, and similar interaction classes before any local
domain-specific variant is allowed
- Removed sections: None - Removed sections: None
- Templates requiring updates: - Templates requiring updates:
- .specify/templates/spec-template.md: added "Compatibility posture" - .specify/templates/spec-template.md: add provider-boundary platform
default block ✅ core check ✅
- .specify/templates/spec-template.md: add cross-cutting shared-pattern - .specify/templates/plan-template.md: add provider-boundary planning
reuse block ✅ fields + constitution check ✅
- .specify/templates/plan-template.md: add shared pattern and system - .specify/templates/tasks-template.md: add provider-boundary task
fit section ✅
- .specify/templates/tasks-template.md: add cross-cutting reuse task
requirements ✅ requirements ✅
- .specify/templates/checklist-template.md: add shared-pattern reuse - .specify/templates/checklist-template.md: add provider-boundary
review checks ✅ review checks ✅
- .github/agents/copilot-instructions.md: added "Pre-production
compatibility check" agent checklist ✅
- Commands checked: - Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present - N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None - Follow-up TODOs: None
@ -66,6 +62,15 @@ ### No Premature Abstraction (ABSTR-001)
- Test convenience alone is not sufficient justification for a new abstraction. - Test convenience alone is not sufficient justification for a new abstraction.
- Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness. - Narrow abstractions are allowed when required for security, tenant isolation, auditability, compliance evidence, or queue/job execution correctness.
### First Provider Is Not Platform Core (PROV-001)
- Microsoft is the current first provider, not the platform core.
- Shared platform-owned contracts, taxonomies, identifiers, compare semantics, and operator vocabulary MUST NOT silently become Microsoft-shaped truth just because Microsoft is the only provider today.
- Shared platform-owned boundaries SHOULD prefer neutral core terms such as `provider`, `connection`, `target scope`, `governed subject`, and `operation` unless the feature is intentionally provider-owned and explicitly bounded.
- Shared core terms at shared boundaries (PROV-002): if a boundary is reused across multiple domains, features, or workflows, the default is neutral platform language rather than provider-specific labels or semantics.
- No accidental deepening of provider coupling (PROV-003): a feature MAY retain provider-specific semantics at a provider-owned seam, but it MUST NOT spread those semantics deeper into platform-core contracts, shared persistence truth, shared taxonomies, or shared UI language without proving that the narrower current-release truth genuinely requires it.
- Shared-boundary review is mandatory (PROV-004): when a feature touches a shared provider/platform seam, the spec, plan, and review MUST state whether the seam is provider-owned or platform-core, what provider-specific semantics remain, and why that choice is the narrowest correct implementation now.
- Prefer bounded extraction over premature generalization (PROV-005): if an existing hotspot is too Microsoft-specific, the default remedy is a bounded normalization or extraction of that hotspot, not a speculative multi-provider framework with unused extension points.
### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001) ### No New Persisted Truth Without Source-of-Truth Need (PERSIST-001)
- New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view. - New tables, persisted entities, or stored artifacts MUST represent real product truth that survives independently of the originating request, run, or view.
- Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time. - Persisted storage is justified only when at least one of these is true: it is a source of truth, has an independent lifecycle, must be audited independently, must outlive its originating run/request, is required for permissions/routing/compliance evidence, or is required for stable operator workflows over time.
@ -1608,6 +1613,7 @@ ### Scope, Compliance, and Review Expectations
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001. - Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands. - Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge. - Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
- Specs and PRs that change operator-facing surfaces MUST classify each - Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change. Decision Surface or workflow-first navigation change.
@ -1625,4 +1631,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance. - **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way. - **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2025-07-19 **Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23

View File

@ -32,18 +32,23 @@ ## Shared Pattern Reuse
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control. - [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded. - [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
## Provider Boundary And Vocabulary
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.
- [ ] CHK011 Any retained provider-specific shared boundary is justified as a bounded current-release exception or an explicit follow-up-spec need instead of becoming permanent platform truth by default.
## Signals, Exceptions, And Test Depth ## Signals, Exceptions, And Test Depth
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`. - [ ] CHK012 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry. - [ ] CHK013 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`. - [ ] CHK014 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists. - [ ] CHK015 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
## Review Outcome ## Review Outcome
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`. - [ ] CHK016 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`. - [ ] CHK017 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes. - [ ] CHK018 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
## Notes ## Notes

View File

@ -54,6 +54,17 @@ ## Shared Pattern & System Fit
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth] - **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule] - **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
## Provider Boundary & Portability Fit
> **Fill when the feature touches 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. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: [yes / no / N/A]
- **Provider-owned seams**: [List or `N/A`]
- **Platform-core seams**: [List or `N/A`]
- **Neutral platform terms / contracts preserved**: [List or `N/A`]
- **Retained provider-specific semantics and why**: [none / short explanation]
- **Bounded extraction or follow-up path**: [none / document-in-feature / follow-up-spec / N/A]
## Constitution Check ## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
@ -82,6 +93,7 @@ ## Constitution Check
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived - Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping - UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth - Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
- Provider boundary (PROV-001): shared provider/platform seams are classified as provider-owned vs platform-core; provider-specific semantics stay out of platform-core contracts, taxonomy, identifiers, compare semantics, and operator vocabulary unless explicitly justified; bounded extraction beats speculative multi-provider frameworks
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve - V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth - Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests - Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests

View File

@ -47,6 +47,16 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links] - **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
- **Review focus**: [What reviewers must verify to prevent parallel local patterns] - **Review focus**: [What reviewers must verify to prevent parallel local patterns]
## 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?**: [yes/no]
- **Boundary classification**: [provider-owned / platform-core / mixed / N/A]
- **Seams affected**: [contracts, models, taxonomies, query keys, labels, filters, compare strategy, etc.]
- **Neutral platform terms preserved or introduced**: [List them or `N/A`]
- **Provider-specific semantics retained and why**: [none / bounded current-release necessity]
- **Why this does not deepen provider coupling accidentally**: [Short explanation]
- **Follow-up path**: [none / document-in-feature / follow-up-spec]
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)* ## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
Use this section to classify UI and surface risk once. If the feature does Use this section to classify UI and surface risk once. If the feature does
@ -234,6 +244,13 @@ ## Requirements *(mandatory)*
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost, - record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
- and make the reviewer focus explicit so parallel local UX paths do not appear silently. - and make the reviewer focus explicit so parallel local UX paths do not appear silently.
**Constitution alignment (PROV-001):** If this feature touches a shared provider/platform seam, the spec MUST:
- classify each touched seam as provider-owned or platform-core,
- keep provider-specific semantics out of platform-core contracts, taxonomies, identifiers, compare semantics, and operator vocabulary unless explicitly justified,
- name the neutral platform terms or shared contracts being preserved,
- explain why any retained provider-specific semantics are the narrowest current-release truth,
- and state whether the remaining hotspot is resolved in-feature or escalated as a follow-up spec.
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe: **Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose, - the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
- the affected validation lane(s) and why they are the narrowest sufficient proof, - the affected validation lane(s) and why they are the narrowest sufficient proof,

View File

@ -51,6 +51,11 @@ # Tasks: [FEATURE NAME]
- extending the shared path when it is sufficient for current-release truth, - extending the shared path when it is sufficient for current-release truth,
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled, - or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception. - and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
**Provider Boundary / Platform Core (PROV-001)**: If this feature touches shared provider/platform seams, tasks MUST include:
- classifying each touched seam as provider-owned or platform-core,
- preventing provider-specific semantics from spreading into platform-core contracts, persistence truth, taxonomies, compare semantics, or operator vocabulary unless explicitly justified,
- implementing bounded normalization or extraction where a current hotspot is too provider-shaped, rather than introducing speculative multi-provider frameworks,
- and recording `document-in-feature` or `follow-up-spec` when a bounded provider-specific hotspot remains.
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include: **UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision, - carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`), - classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),

View File

@ -1 +1 @@
{"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776593337482,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776593337489,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776593337495,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776593337500,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}} {"name":"color-name","version":"1.1.4","requiresBuild":false,"files":{"package.json":{"checkedAt":1776976148151,"integrity":"sha512-E5CrPeTNIaZAftwqMJpkT8PDNamUJUrubHLTZ6Rjn3l9RvJKSLw6MGXT6SAcRHV3ltLOSTOa1HvkQ7/GUOoaHw==","mode":438,"size":607},"index.js":{"checkedAt":1776976148156,"integrity":"sha512-nek+57RYqda5dmQCKQmtJafLicLP3Y7hmqLhJlZrenqTCyQUOip2+D2/8Z8aZ7CnHek+irJIcgwu4kM5boaUUQ==","mode":438,"size":4617},"LICENSE":{"checkedAt":1776976148162,"integrity":"sha512-/B1lNSwRTHWUyb7fW+QyujnUJv6vUL+PfFLTJ4EyPIS/yaaFMa77VYyX6+RucS4dNdhguh4aarSLSnm4lAklQA==","mode":438,"size":1085},"README.md":{"checkedAt":1776976148168,"integrity":"sha512-/hmGUPmp0gXgx/Ov5oGW6DAU3c4h4aLMa/bE1TkpZHPU7dCx5JFS9hoYM4/+919EWCaPtBhWzK+6pG/6xdx+Ng==","mode":438,"size":384}}}

View File

@ -1 +1 @@
{"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776593336106,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776593336125,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776593336132,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776593336138,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776593336144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}} {"name":"@types/estree","version":"1.0.8","requiresBuild":false,"files":{"LICENSE":{"checkedAt":1776976148127,"integrity":"sha512-HQaIQk9pwOcyKutyDk4o2a87WnotwYuLGYFW43emGm4FvIJFKPyg+OYaw5sTegKAKf+C5SKa1ACjzCLivbaHrQ==","mode":420,"size":1141},"README.md":{"checkedAt":1776976148139,"integrity":"sha512-alZQw4vOCWtDJlTmYSm+aEvD0weTLtGERCy5tNbpyvPI5F2j9hEWxHuUdwL+TZU2Nhdx7EGRhitAiv0xuSxaeg==","mode":420,"size":458},"flow.d.ts":{"checkedAt":1776976148143,"integrity":"sha512-f3OqA/2H/A62ZLT0qAZlUCUAiI89dMFcY+XrAU08dNgwHhXSQmFeMc7w/Ee7RE8tHU5RXFoQazarmCUsnCvXxg==","mode":420,"size":4801},"index.d.ts":{"checkedAt":1776976148144,"integrity":"sha512-YwR3YirWettZcjZgr7aNimg/ibEuP+6JMqAvL+cT6ubq2ctYKL9Xv+PgBssGCPES01PG5zKTHSvhShXCjXOrDg==","mode":420,"size":18944},"package.json":{"checkedAt":1776976148144,"integrity":"sha512-KaEBTHEFL2oVUvCrjSJR/H812XIaeRGbSZFP8DBb2Hon+IQwND0zz7oRvrXTm2AzzjneqH+pkB2Lusw29yJ/WA==","mode":420,"size":829}}}

View File

@ -1 +1 @@
{"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776593335180,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776593335194,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776593335198,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776593335206,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776593335213,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776593335219,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776593335230,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776593335236,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776593335243,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776593335251,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776593335258,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776593335264,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776593335271,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776593335278,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}} {"name":"tslib","version":"2.8.1","requiresBuild":false,"files":{"tslib.es6.html":{"checkedAt":1776976148162,"integrity":"sha512-aoAR2zaxE9UtcXO4kE9FbPBgIZEVk7u3Z+nEPmDo6rwcYth07KxrVZejVEdy2XmKvkkcb8O/XM9UK3bPc1iMPw==","mode":420,"size":36},"tslib.html":{"checkedAt":1776976148164,"integrity":"sha512-4dCvZ5WYJpcbIJY4RPUhOBbFud1156Rr7RphuR12/+mXKUeIpCxol2/uWL4WDFNNlSH909M2AY4fiLWJo8+fTw==","mode":420,"size":32},"modules/index.js":{"checkedAt":1776976148166,"integrity":"sha512-DqWTtBt/Q47Jm4z8VzCLSiG/2R+Mwqy8uB60ithBWyofDYamF5C4icYdqbq/NP2IE/TefCT/03uAwA5mujzR7A==","mode":420,"size":1416},"tslib.es6.js":{"checkedAt":1776976148173,"integrity":"sha512-FugydTgfIjlaQrbH9gaIh59iXw8keW2311ILz3FBWn1IHLwPcmWte+ZE8UeXXGTQRc2E8NhQSCzYA6/zX36+7w==","mode":420,"size":19215},"tslib.js":{"checkedAt":1776976148180,"integrity":"sha512-7Gj/3vlZdba9iZH2H2up34pBk5UfN1tWQl3/TjsHzw3Oipw/stl6nko8k4jk+MeDeLPJE3rKz3VoQG5XmgwSmg==","mode":420,"size":23382},"modules/package.json":{"checkedAt":1776976148185,"integrity":"sha512-vm8hQn5MuoMkjJYvBBHTAtsdrcuXmVrKZwL3FEq32oGiKFhY562FoUQTbXv24wk0rwJVpgribUCOIU98IaS9Mg==","mode":420,"size":26},"package.json":{"checkedAt":1776976148187,"integrity":"sha512-72peSY+xgEHIo+YSpUbUl6qsExQ5ZlgeiDVDAiy4QdVmmBkK7RAB/07CCX3gg0SyvvQVJiGgAD36ub3rgE4QCg==","mode":420,"size":1219},"README.md":{"checkedAt":1776976148192,"integrity":"sha512-kCH2ENYjhlxwI7ae89ymMIP2tZeNcJJOcqnfifnmHQiHeK4mWnGc4w8ygoiUIpG1qyaurZkRSrYtwHCEIMNhbA==","mode":420,"size":4033},"SECURITY.md":{"checkedAt":1776976148195,"integrity":"sha512-ix30VBNb4RQLa5M2jgfD6IJ9+1XKmeREKrOYv7rDoWGZCin0605vEx3tTAVb5kNvteCwZwBC+nEGfQ4jHLg9Fw==","mode":420,"size":2757},"tslib.es6.mjs":{"checkedAt":1776976148199,"integrity":"sha512-q8VhXPTjmn6KDh3j6Ewn0V3siY1zNdvXvIUNN36llJUtO5cDafldf1Y2zzToBAbgOdh2pjFks7lFDRzZ/LZnDw==","mode":420,"size":17648},"modules/index.d.ts":{"checkedAt":1776976148200,"integrity":"sha512-XcNprVMjDjhbWmH3OTNZV91Uh9sDaCs8oZa3J7g5wMUHsdMJRENmv4XQ/8yqMlTUxKopv8uiztELREI7cw8BDg==","mode":420,"size":801},"tslib.d.ts":{"checkedAt":1776976148210,"integrity":"sha512-kqzM5TLHelP5iJBElSYyBRocQd2XmWsGIzOG6+Mv+CB7KhoZ6BoFioWM3RR2OCm1p96bbSCGnfHo2rozV/WJYQ==","mode":420,"size":18317},"CopyrightNotice.txt":{"checkedAt":1776976148214,"integrity":"sha512-C0myUddnUhhpZ/UcD9yZyMWodQV4fT2wxcfqb/ToD0Z98nB9WfWBl6koNVWJ+8jzeGWP6wQjz9zdX7Unua0/SQ==","mode":420,"size":822},"LICENSE.txt":{"checkedAt":1776976148225,"integrity":"sha512-9cs1Im06/fLAPBpXOY8fHMD2LgUM3kREaKlOX7S6fLWwbG5G+UqlUrqdkTKloRPeDghECezxOiUfzvW6lnEjDg==","mode":420,"size":655}}}

View File

@ -67,7 +67,6 @@ public function handle(): int
'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'), 'name' => (string) ($scenarioConfig['tenant_name'] ?? 'Spec 180 Blocked Backup Tenant'),
'tenant_id' => $tenantRouteKey, 'tenant_id' => $tenantRouteKey,
'app_certificate_thumbprint' => null, 'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => Tenant::STATUS_ACTIVE, 'status' => Tenant::STATUS_ACTIVE,
'environment' => 'dev', 'environment' => 'dev',

View File

@ -598,7 +598,9 @@ public function content(Schema $schema): Schema
->tooltip(fn (): ?string => $this->canStartAnyBootstrap() ->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
? null ? null
: 'You do not have permission to start bootstrap actions.') : 'You do not have permission to start bootstrap actions.')
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))), ->action(fn (Get $get) => $this->startBootstrap(
$this->normalizeBootstrapOperationTypes((array) ($get('bootstrap_operation_types') ?? [])),
)),
]), ]),
Text::make(fn (): string => $this->bootstrapRunsLabel()) Text::make(fn (): string => $this->bootstrapRunsLabel())
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''), ->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
@ -606,9 +608,11 @@ public function content(Schema $schema): Schema
]) ])
->afterValidation(function (): void { ->afterValidation(function (): void {
$types = $this->data['bootstrap_operation_types'] ?? []; $types = $this->data['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($types) $this->selectedBootstrapOperationTypes = $this->normalizeBootstrapOperationTypes(
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')) is_array($types) ? $types : [],
: []; );
$this->persistBootstrapSelection($this->selectedBootstrapOperationTypes);
$this->touchOnboardingSessionStep('bootstrap'); $this->touchOnboardingSessionStep('bootstrap');
}), }),
@ -642,6 +646,10 @@ public function content(Schema $schema): Schema
->badge() ->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()), ->color(fn (): string => $this->completionSummaryBootstrapColor()),
]), ]),
Callout::make('Bootstrap needs attention')
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
->warning()
->visible(fn (): bool => $this->showCompletionSummaryBootstrapRecovery()),
Callout::make('After completion') Callout::make('After completion')
->description('This action is recorded in the audit log and cannot be undone from this wizard.') ->description('This action is recorded in the audit log and cannot be undone from this wizard.')
->info() ->info()
@ -733,10 +741,111 @@ private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|str
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? []; $bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes) $this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== '')) ? $this->normalizeBootstrapOperationTypes($bootstrapTypes)
: []; : [];
} }
/**
* @param array<int|string, mixed> $operationTypes
* @return array<int, string>
*/
private function normalizeBootstrapOperationTypes(array $operationTypes): array
{
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
$normalized = [];
foreach ($operationTypes as $key => $value) {
if (is_string($value)) {
$normalizedValue = trim($value);
if ($normalizedValue !== '' && in_array($normalizedValue, $supportedTypes, true)) {
$normalized[] = $normalizedValue;
}
continue;
}
if (! is_string($key) || trim($key) === '') {
continue;
}
$isSelected = match (true) {
is_bool($value) => $value,
is_int($value) => $value === 1,
is_string($value) => in_array(strtolower(trim($value)), ['1', 'true', 'on', 'yes'], true),
default => false,
};
$normalizedKey = trim($key);
if ($isSelected && in_array($normalizedKey, $supportedTypes, true)) {
$normalized[] = $normalizedKey;
}
}
return array_values(array_unique($normalized));
}
/**
* @return array<string, string>
*/
private function supportedBootstrapCapabilities(): array
{
return [
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
];
}
/**
* @param array<int, string> $operationTypes
*/
private function persistBootstrapSelection(array $operationTypes): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return;
}
$normalized = $this->normalizeBootstrapOperationTypes($operationTypes);
$existing = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
$existing = is_array($existing)
? $this->normalizeBootstrapOperationTypes($existing)
: [];
if ($normalized === $existing) {
return;
}
try {
$this->setOnboardingSession($this->mutationService()->mutate(
draft: $this->onboardingSession,
actor: $user,
expectedVersion: $this->expectedDraftVersion(),
incrementVersion: false,
mutator: function (TenantOnboardingSession $draft) use ($normalized): void {
$state = is_array($draft->state) ? $draft->state : [];
$state['bootstrap_operation_types'] = $normalized;
$draft->state = $state;
},
));
} catch (OnboardingDraftConflictException) {
$this->handleDraftConflict();
return;
} catch (OnboardingDraftImmutableException) {
$this->handleImmutableDraft();
return;
}
}
/** /**
* @return Collection<int, TenantOnboardingSession> * @return Collection<int, TenantOnboardingSession>
*/ */
@ -1464,6 +1573,7 @@ private function initializeWizardData(): void
// Ensure all entangled schema state paths exist at render time. // Ensure all entangled schema state paths exist at render time.
// Livewire v4 can throw when entangling to missing nested array keys. // Livewire v4 can throw when entangling to missing nested array keys.
$this->data['notes'] ??= ''; $this->data['notes'] ??= '';
$this->data['bootstrap_operation_types'] ??= [];
$this->data['override_blocked'] ??= false; $this->data['override_blocked'] ??= false;
$this->data['override_reason'] ??= ''; $this->data['override_reason'] ??= '';
$this->data['new_connection'] ??= []; $this->data['new_connection'] ??= [];
@ -1534,7 +1644,7 @@ private function initializeWizardData(): void
$types = $draft->state['bootstrap_operation_types'] ?? null; $types = $draft->state['bootstrap_operation_types'] ?? null;
if (is_array($types)) { if (is_array($types)) {
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')); $this->data['bootstrap_operation_types'] = $this->normalizeBootstrapOperationTypes($types);
} }
} }
@ -2966,7 +3076,7 @@ public function startBootstrap(array $operationTypes): void
} }
$registry = app(ProviderOperationRegistry::class); $registry = app(ProviderOperationRegistry::class);
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== ''))); $types = $this->normalizeBootstrapOperationTypes($operationTypes);
$types = array_values(array_filter( $types = array_values(array_filter(
$types, $types,
@ -3236,18 +3346,18 @@ private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, str
private function resolveBootstrapCapability(string $operationType): ?string private function resolveBootstrapCapability(string $operationType): ?string
{ {
return match ($operationType) { return $this->supportedBootstrapCapabilities()[$operationType] ?? null;
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
default => null,
};
} }
private function canStartAnyBootstrap(): bool private function canStartAnyBootstrap(): bool
{ {
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC) foreach ($this->supportedBootstrapCapabilities() as $capability) {
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC) if ($this->currentUserCan($capability)) {
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP); return true;
}
}
return false;
} }
private function currentUserCan(string $capability): bool private function currentUserCan(string $capability): bool
@ -3498,33 +3608,59 @@ private function completionSummaryVerificationDetail(): string
private function completionSummaryBootstrapLabel(): string private function completionSummaryBootstrapLabel(): string
{ {
if (! $this->onboardingSession instanceof TenantOnboardingSession) { if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'Skipped'; return $this->completionSummarySelectedBootstrapTypes() === []
? 'Skipped'
: 'Selected';
}
if ($this->completionSummaryBootstrapActionRequiredDetail() !== null) {
return 'Action required';
} }
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : []; $runs = is_array($runs) ? $runs : [];
if ($runs === []) { if ($runs !== []) {
return 'Skipped'; return 'Started';
} }
return 'Started'; return $this->completionSummarySelectedBootstrapTypes() === []
? 'Skipped'
: 'Selected';
} }
private function completionSummaryBootstrapDetail(): string private function completionSummaryBootstrapDetail(): string
{ {
if (! $this->onboardingSession instanceof TenantOnboardingSession) { if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'No bootstrap actions selected'; $selectedTypes = $this->completionSummarySelectedBootstrapTypes();
return $selectedTypes === []
? 'No bootstrap actions selected'
: sprintf('%d action(s) selected', count($selectedTypes));
} }
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : []; $runs = is_array($runs) ? $runs : [];
$selectedTypes = $this->completionSummarySelectedBootstrapTypes();
$actionRequiredDetail = $this->completionSummaryBootstrapActionRequiredDetail();
if ($runs === []) { if ($selectedTypes === []) {
return 'No bootstrap actions selected'; return 'No bootstrap actions selected';
} }
return sprintf('%d operation(s) started', count($runs)); if ($actionRequiredDetail !== null) {
return $actionRequiredDetail;
}
if ($runs === []) {
return sprintf('%d action(s) selected', count($selectedTypes));
}
if (count($runs) < count($selectedTypes)) {
return sprintf('%d of %d action(s) started', count($runs), count($selectedTypes));
}
return sprintf('%d action(s) started', count($runs));
} }
private function completionSummaryBootstrapSummary(): string private function completionSummaryBootstrapSummary(): string
@ -3536,11 +3672,130 @@ private function completionSummaryBootstrapSummary(): string
); );
} }
private function showCompletionSummaryBootstrapRecovery(): bool
{
return $this->completionSummaryBootstrapActionRequiredDetail() !== null;
}
private function completionSummaryBootstrapRecoveryMessage(): string
{
return 'Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.';
}
private function completionSummaryBootstrapColor(): string private function completionSummaryBootstrapColor(): string
{ {
return $this->completionSummaryBootstrapLabel() === 'Started' return match ($this->completionSummaryBootstrapLabel()) {
? 'info' 'Action required' => 'warning',
: 'gray'; 'Started' => 'info',
'Selected' => 'warning',
default => 'gray',
};
}
private function completionSummaryBootstrapActionRequiredDetail(): ?string
{
$reasonCode = $this->completionSummaryBootstrapReasonCode();
if (! in_array($reasonCode, ['bootstrap_failed', 'bootstrap_partial_failure'], true)) {
return null;
}
$run = $this->completionSummaryBootstrapFailedRun();
if (! $run instanceof OperationRun) {
return $reasonCode === 'bootstrap_partial_failure'
? 'A bootstrap action needs attention'
: 'A bootstrap action failed';
}
$context = is_array($run->context ?? null) ? $run->context : [];
$operatorLabel = data_get($context, 'reason_translation.operator_label');
if (is_string($operatorLabel) && trim($operatorLabel) !== '') {
return trim($operatorLabel);
}
return match ($run->outcome) {
OperationRunOutcome::PartiallySucceeded->value => 'A bootstrap action needs attention',
OperationRunOutcome::Blocked->value => 'A bootstrap action was blocked',
default => 'A bootstrap action failed',
};
}
private function completionSummaryBootstrapReasonCode(): ?string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return null;
}
$reasonCode = $this->lifecycleService()->snapshot($this->onboardingSession)['reason_code'] ?? null;
return is_string($reasonCode) ? $reasonCode : null;
}
private function completionSummaryBootstrapFailedRun(): ?OperationRun
{
return once(function (): ?OperationRun {
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return null;
}
$runMap = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
if (! is_array($runMap)) {
return null;
}
$runIds = array_values(array_filter(array_map(
static fn (mixed $value): ?int => is_numeric($value) ? (int) $value : null,
$runMap,
)));
if ($runIds === []) {
return null;
}
return OperationRun::query()
->whereIn('id', $runIds)
->where('status', OperationRunStatus::Completed->value)
->whereIn('outcome', [
OperationRunOutcome::Blocked->value,
OperationRunOutcome::Failed->value,
OperationRunOutcome::PartiallySucceeded->value,
])
->latest('id')
->first();
});
}
/**
* @return array<int, string>
*/
private function completionSummarySelectedBootstrapTypes(): array
{
$selectedTypes = $this->data['bootstrap_operation_types'] ?? null;
if (is_array($selectedTypes)) {
$normalized = $this->normalizeBootstrapOperationTypes($selectedTypes);
if ($normalized !== []) {
return $normalized;
}
}
if ($this->selectedBootstrapOperationTypes !== []) {
return $this->normalizeBootstrapOperationTypes($this->selectedBootstrapOperationTypes);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return [];
}
$persistedTypes = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
return is_array($persistedTypes)
? $this->normalizeBootstrapOperationTypes($persistedTypes)
: [];
} }
public function completeOnboarding(): void public function completeOnboarding(): void
@ -4139,9 +4394,10 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
private function bootstrapOperationOptions(): array private function bootstrapOperationOptions(): array
{ {
$registry = app(ProviderOperationRegistry::class); $registry = app(ProviderOperationRegistry::class);
$supportedTypes = array_keys($this->supportedBootstrapCapabilities());
return collect($registry->all()) return collect($registry->all())
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check') ->filter(fn (array $definition, string $type): bool => in_array($type, $supportedTypes, true))
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)]) ->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
->all(); ->all();
} }

View File

@ -9,6 +9,7 @@
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
@ -840,7 +841,17 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
private static function profileNextStep(BaselineProfile $profile): string private static function profileNextStep(BaselineProfile $profile): string
{ {
return match (self::compareAvailabilityReason($profile)) { $compareAvailabilityReason = self::compareAvailabilityReason($profile);
if ($compareAvailabilityReason === null) {
$latestCaptureEnvelope = self::latestBaselineCaptureEnvelope($profile);
if ($latestCaptureEnvelope instanceof ReasonResolutionEnvelope && trim($latestCaptureEnvelope->shortExplanation) !== '') {
return $latestCaptureEnvelope->shortExplanation;
}
}
return match ($compareAvailabilityReason) {
BaselineReasonCodes::COMPARE_INVALID_SCOPE, BaselineReasonCodes::COMPARE_INVALID_SCOPE,
BaselineReasonCodes::COMPARE_MIXED_SCOPE, BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.', BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
@ -858,6 +869,30 @@ private static function latestAttemptedSnapshot(BaselineProfile $profile): ?Base
return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile); return app(BaselineSnapshotTruthResolver::class)->resolveLatestAttemptedSnapshot($profile);
} }
private static function latestBaselineCaptureEnvelope(BaselineProfile $profile): ?ReasonResolutionEnvelope
{
$run = OperationRun::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('type', 'baseline_capture')
->where('context->baseline_profile_id', (int) $profile->getKey())
->where('status', 'completed')
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return null;
}
$reasonCode = data_get($run->context, 'reason_code');
if (! is_string($reasonCode) || trim($reasonCode) === '') {
return null;
}
return app(ReasonPresenter::class)->forOperationRun($run, 'artifact_truth');
}
private static function compareAvailabilityReason(BaselineProfile $profile): ?string private static function compareAvailabilityReason(BaselineProfile $profile): ?string
{ {
$status = $profile->status instanceof BaselineProfileStatus $status = $profile->status instanceof BaselineProfileStatus

View File

@ -19,6 +19,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Rbac\WorkspaceUiEnforcement;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -105,15 +106,10 @@ private function captureAction(): Action
if (! $result['ok']) { if (! $result['ok']) {
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown'; $reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
$translation = app(ReasonPresenter::class)->forArtifactTruth($reasonCode, 'artifact_truth');
$message = match ($reasonCode) { $message = is_string($translation?->shortExplanation) && trim($translation->shortExplanation) !== ''
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED => 'Full-content baseline capture is currently disabled for controlled rollout.', ? trim($translation->shortExplanation)
BaselineReasonCodes::CAPTURE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.', : 'Reason: '.str_replace('.', ' ', $reasonCode);
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => 'The selected tenant is not available for this baseline profile.',
BaselineReasonCodes::CAPTURE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before capturing.',
BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for capture.',
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
};
Notification::make() Notification::make()
->title('Cannot start capture') ->title('Cannot start capture')

View File

@ -4,6 +4,7 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
@ -54,6 +55,8 @@ public function __invoke(
error: $error, error: $error,
); );
$this->invalidateResumableOnboardingVerificationState($tenant, $connection);
$legacyStatus = $status === 'ok' ? 'success' : 'failed'; $legacyStatus = $status === 'ok' ? 'success' : 'failed';
$auditMetadata = [ $auditMetadata = [
'source' => 'admin.consent.callback', 'source' => 'admin.consent.callback',
@ -98,6 +101,7 @@ public function __invoke(
'status' => $status, 'status' => $status,
'error' => $error, 'error' => $error,
'consentGranted' => $consentGranted, 'consentGranted' => $consentGranted,
'verificationStateLabel' => $this->verificationStateLabel($connection),
]); ]);
} }
@ -197,4 +201,48 @@ private function parseState(?string $state): ?string
return $state; return $state;
} }
private function verificationStateLabel(ProviderConnection $connection): string
{
$verificationStatus = $connection->verification_status instanceof ProviderVerificationStatus
? $connection->verification_status
: ProviderVerificationStatus::tryFrom((string) $connection->verification_status);
if ($verificationStatus === ProviderVerificationStatus::Unknown) {
return $connection->consent_status === ProviderConsentStatus::Granted
? 'Needs verification'
: 'Not verified';
}
return ucfirst(str_replace('_', ' ', $verificationStatus?->value ?? 'unknown'));
}
private function invalidateResumableOnboardingVerificationState(Tenant $tenant, ProviderConnection $connection): void
{
TenantOnboardingSession::query()
->where('tenant_id', (int) $tenant->getKey())
->resumable()
->each(function (TenantOnboardingSession $draft) use ($connection): void {
$state = is_array($draft->state) ? $draft->state : [];
$providerConnectionId = $state['provider_connection_id'] ?? null;
$providerConnectionId = is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
if ($providerConnectionId !== null && $providerConnectionId !== (int) $connection->getKey()) {
return;
}
unset(
$state['verification_operation_run_id'],
$state['verification_run_id'],
$state['bootstrap_operation_runs'],
$state['bootstrap_operation_types'],
$state['bootstrap_run_ids'],
);
$state['connection_recently_updated'] = true;
$draft->state = $state;
$draft->save();
});
}
} }

View File

@ -11,6 +11,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineContentCapturePhase; use App\Services\Baselines\BaselineContentCapturePhase;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotItemNormalizer; use App\Services\Baselines\BaselineSnapshotItemNormalizer;
@ -29,7 +30,6 @@
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -71,13 +71,24 @@ public function handle(
InventoryMetaContract $metaContract, InventoryMetaContract $metaContract,
AuditLogger $auditLogger, AuditLogger $auditLogger,
OperationRunService $operationRunService, OperationRunService $operationRunService,
?CurrentStateHashResolver $hashResolver = null, mixed $arg5 = null,
?BaselineContentCapturePhase $contentCapturePhase = null, mixed $arg6 = null,
?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null, ?BaselineSnapshotItemNormalizer $snapshotItemNormalizer = null,
?BaselineFullContentRolloutGate $rolloutGate = null, ?BaselineFullContentRolloutGate $rolloutGate = null,
): void { ): void {
$hashResolver ??= app(CurrentStateHashResolver::class); $captureService = $arg5 instanceof BaselineCaptureService
$contentCapturePhase ??= app(BaselineContentCapturePhase::class); ? $arg5
: app(BaselineCaptureService::class);
$hashResolver = $arg5 instanceof CurrentStateHashResolver
? $arg5
: ($arg6 instanceof CurrentStateHashResolver
? $arg6
: app(CurrentStateHashResolver::class));
$contentCapturePhase = $arg5 instanceof BaselineContentCapturePhase
? $arg5
: ($arg6 instanceof BaselineContentCapturePhase
? $arg6
: app(BaselineContentCapturePhase::class));
$snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class); $snapshotItemNormalizer ??= app(BaselineSnapshotItemNormalizer::class);
$rolloutGate ??= app(BaselineFullContentRolloutGate::class); $rolloutGate ??= app(BaselineFullContentRolloutGate::class);
@ -118,10 +129,124 @@ public function handle(
$rolloutGate->assertEnabled(); $rolloutGate->assertEnabled();
} }
$latestInventorySyncRun = $this->resolveLatestInventorySyncRun($sourceTenant); $previousCurrentSnapshot = $profile->resolveCurrentConsumableSnapshot();
$latestInventorySyncRunId = $latestInventorySyncRun instanceof OperationRun $previousCurrentSnapshotId = $previousCurrentSnapshot instanceof BaselineSnapshot
? (int) $latestInventorySyncRun->getKey() ? (int) $previousCurrentSnapshot->getKey()
: null; : null;
$previousCurrentSnapshotExists = $previousCurrentSnapshotId !== null;
$preflightEligibility = is_array(data_get($context, 'baseline_capture.eligibility'))
? data_get($context, 'baseline_capture.eligibility')
: [];
$inventoryEligibility = $captureService->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, $truthfulTypes);
$latestInventorySyncRunId = is_numeric($inventoryEligibility['inventory_sync_run_id'] ?? null)
? (int) $inventoryEligibility['inventory_sync_run_id']
: null;
$eligibilityContext = $captureService->eligibilityContextPayload($inventoryEligibility, phase: 'runtime_recheck');
$eligibilityContext['changed_after_enqueue'] = ($preflightEligibility['ok'] ?? null) === true
&& ! ($inventoryEligibility['ok'] ?? false);
$eligibilityContext['preflight_inventory_sync_run_id'] = is_numeric($preflightEligibility['inventory_sync_run_id'] ?? null)
? (int) $preflightEligibility['inventory_sync_run_id']
: null;
$eligibilityContext['preflight_reason_code'] = is_string($preflightEligibility['reason_code'] ?? null)
? (string) $preflightEligibility['reason_code']
: null;
$context['baseline_capture'] = array_merge(
is_array($context['baseline_capture'] ?? null) ? $context['baseline_capture'] : [],
[
'inventory_sync_run_id' => $latestInventorySyncRunId,
'eligibility' => $eligibilityContext,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
],
);
$this->operationRun->update(['context' => $context]);
$this->operationRun->refresh();
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
if (! ($inventoryEligibility['ok'] ?? false)) {
$reasonCode = is_string($inventoryEligibility['reason_code'] ?? null)
? (string) $inventoryEligibility['reason_code']
: BaselineReasonCodes::CAPTURE_INVENTORY_MISSING;
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
];
$blockedContext = $context;
$blockedContext['reason_code'] = $reasonCode;
$blockedContext['baseline_capture'] = array_merge(
is_array($blockedContext['baseline_capture'] ?? null) ? $blockedContext['baseline_capture'] : [],
[
'reason_code' => $reasonCode,
'subjects_total' => 0,
'current_baseline_changed' => false,
],
);
$blockedContext['result'] = array_merge(
is_array($blockedContext['result'] ?? null) ? $blockedContext['result'] : [],
[
'current_baseline_changed' => false,
],
);
$this->operationRun->update([
'context' => $blockedContext,
'summary_counts' => $summaryCounts,
]);
$this->operationRun->refresh();
$this->auditStarted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
effectiveScope: $effectiveScope,
inventorySyncRunId: $latestInventorySyncRunId,
);
$operationRunService->finalizeBlockedRun(
run: $this->operationRun,
reasonCode: $reasonCode,
message: $this->blockedInventoryMessage(
$reasonCode,
(bool) ($eligibilityContext['changed_after_enqueue'] ?? false),
),
);
$this->operationRun->refresh();
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
snapshot: null,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
inventorySyncRunId: $latestInventorySyncRunId,
wasNewSnapshot: false,
evidenceCaptureStats: [
'requested' => 0,
'succeeded' => 0,
'skipped' => 0,
'failed' => 0,
'throttled' => 0,
],
gaps: [
'count' => 0,
'by_reason' => [],
],
currentBaselineChanged: false,
reasonCode: $reasonCode,
);
return;
}
$inventoryResult = $this->collectInventorySubjects( $inventoryResult = $this->collectInventorySubjects(
sourceTenant: $sourceTenant, sourceTenant: $sourceTenant,
@ -154,6 +279,7 @@ public function handle(
'failed' => 0, 'failed' => 0,
'throttled' => 0, 'throttled' => 0,
]; ];
$phaseResult = [];
$phaseGaps = []; $phaseGaps = [];
$resumeToken = null; $resumeToken = null;
@ -222,6 +348,91 @@ public function handle(
], ],
]; ];
if ($subjectsTotal === 0) {
$snapshotResult = $this->captureNoDataSnapshotArtifact(
$profile,
$identityHash,
$snapshotSummary,
);
$snapshot = $snapshotResult['snapshot'];
$wasNewSnapshot = $snapshotResult['was_new_snapshot'];
$summaryCounts = [
'total' => 0,
'processed' => 0,
'succeeded' => 0,
'failed' => 0,
];
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
$updatedContext['reason_code'] = BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS;
$updatedContext['baseline_capture'] = array_merge(
is_array($updatedContext['baseline_capture'] ?? null) ? $updatedContext['baseline_capture'] : [],
[
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'subjects_total' => 0,
'inventory_sync_run_id' => $latestInventorySyncRunId,
'evidence_capture' => $phaseStats,
'gaps' => [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
'subjects' => is_array($phaseResult['gap_subjects'] ?? null) && $phaseResult['gap_subjects'] !== []
? array_values($phaseResult['gap_subjects'])
: null,
],
'resume_token' => $resumeToken,
'current_baseline_changed' => false,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
],
);
$updatedContext['result'] = array_merge(
is_array($updatedContext['result'] ?? null) ? $updatedContext['result'] : [],
[
'snapshot_id' => (int) $snapshot->getKey(),
'snapshot_identity_hash' => $identityHash,
'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => 0,
'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
'current_baseline_changed' => false,
],
);
$this->operationRun->update([
'context' => $updatedContext,
'summary_counts' => $summaryCounts,
]);
$this->operationRun->refresh();
$operationRunService->updateRun(
$this->operationRun,
status: OperationRunStatus::Completed->value,
outcome: OperationRunOutcome::PartiallySucceeded->value,
summaryCounts: $summaryCounts,
);
$this->operationRun->refresh();
$this->auditCompleted(
auditLogger: $auditLogger,
tenant: $sourceTenant,
profile: $profile,
snapshot: $snapshot,
initiator: $initiator,
captureMode: $captureMode,
subjectsTotal: 0,
inventorySyncRunId: $latestInventorySyncRunId,
wasNewSnapshot: $wasNewSnapshot,
evidenceCaptureStats: $phaseStats,
gaps: [
'count' => $gapsCount,
'by_reason' => $gapsByReason,
],
currentBaselineChanged: false,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
return;
}
$snapshotResult = $this->captureSnapshotArtifact( $snapshotResult = $this->captureSnapshotArtifact(
$profile, $profile,
$identityHash, $identityHash,
@ -236,6 +447,9 @@ public function handle(
$profile->update(['active_snapshot_id' => $snapshot->getKey()]); $profile->update(['active_snapshot_id' => $snapshot->getKey()]);
} }
$profile->refresh();
$currentBaselineChanged = $this->currentBaselineChanged($profile, $previousCurrentSnapshotId);
$warningsRecorded = $gapsByReason !== [] || $resumeToken !== null; $warningsRecorded = $gapsByReason !== [] || $resumeToken !== null;
$warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0); $warningsRecorded = $warningsRecorded || ($captureMode === BaselineCaptureMode::FullContent && ($snapshotItems['fidelity_counts']['meta'] ?? 0) > 0);
$outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value; $outcome = $warningsRecorded ? OperationRunOutcome::PartiallySucceeded->value : OperationRunOutcome::Succeeded->value;
@ -269,6 +483,9 @@ public function handle(
: null, : null,
], ],
'resume_token' => $resumeToken, 'resume_token' => $resumeToken,
'current_baseline_changed' => $currentBaselineChanged,
'previous_current_snapshot_id' => $previousCurrentSnapshotId,
'previous_current_snapshot_exists' => $previousCurrentSnapshotExists,
], ],
); );
$updatedContext['result'] = [ $updatedContext['result'] = [
@ -277,6 +494,7 @@ public function handle(
'was_new_snapshot' => $wasNewSnapshot, 'was_new_snapshot' => $wasNewSnapshot,
'items_captured' => $snapshotItems['items_count'], 'items_captured' => $snapshotItems['items_count'],
'snapshot_lifecycle' => $snapshot->lifecycleState()->value, 'snapshot_lifecycle' => $snapshot->lifecycleState()->value,
'current_baseline_changed' => $currentBaselineChanged,
]; ];
$this->operationRun->update(['context' => $updatedContext]); $this->operationRun->update(['context' => $updatedContext]);
@ -295,6 +513,8 @@ public function handle(
'count' => $gapsCount, 'count' => $gapsCount,
'by_reason' => $gapsByReason, 'by_reason' => $gapsByReason,
], ],
currentBaselineChanged: $currentBaselineChanged,
reasonCode: null,
); );
} }
@ -651,6 +871,51 @@ private function captureSnapshotArtifact(
} }
} }
/**
* @param array<string, mixed> $summaryJsonb
* @return array{snapshot: BaselineSnapshot, was_new_snapshot: bool}
*/
private function captureNoDataSnapshotArtifact(
BaselineProfile $profile,
string $identityHash,
array $summaryJsonb,
): array {
$snapshot = $this->createBuildingSnapshot($profile, $identityHash, $summaryJsonb, 0);
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: 0,
persistedItems: 0,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
$snapshot->markIncomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS, [
'expected_identity_hash' => $identityHash,
'expected_items' => 0,
'persisted_items' => 0,
'producer_run_id' => (int) $this->operationRun->getKey(),
'was_empty_capture' => true,
]);
$snapshot->refresh();
$this->rememberSnapshotOnRun(
snapshot: $snapshot,
identityHash: $identityHash,
wasNewSnapshot: true,
expectedItems: 0,
persistedItems: 0,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
);
return [
'snapshot' => $snapshot,
'was_new_snapshot' => true,
];
}
private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot private function findExistingConsumableSnapshot(BaselineProfile $profile, string $identityHash): ?BaselineSnapshot
{ {
$existing = BaselineSnapshot::query() $existing = BaselineSnapshot::query()
@ -783,6 +1048,32 @@ private function countByPolicyType(array $items): array
return $counts; return $counts;
} }
private function currentBaselineChanged(BaselineProfile $profile, ?int $previousCurrentSnapshotId): bool
{
$currentSnapshot = $profile->resolveCurrentConsumableSnapshot();
$currentSnapshotId = $currentSnapshot instanceof BaselineSnapshot
? (int) $currentSnapshot->getKey()
: null;
return $currentSnapshotId !== null && $currentSnapshotId !== $previousCurrentSnapshotId;
}
private function blockedInventoryMessage(string $reasonCode, bool $changedAfterEnqueue): string
{
return match ($reasonCode) {
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => $changedAfterEnqueue
? 'Capture blocked because the latest inventory sync changed after the run was queued.'
: 'Capture blocked because the latest inventory sync was blocked.',
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => $changedAfterEnqueue
? 'Capture blocked because the latest inventory sync failed after the run was queued.'
: 'Capture blocked because the latest inventory sync failed.',
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => $changedAfterEnqueue
? 'Capture blocked because the latest inventory coverage became unusable after the run was queued.'
: 'Capture blocked because the latest inventory coverage was not usable for this baseline scope.',
default => 'Capture blocked because no credible inventory basis was available.',
};
}
private function auditStarted( private function auditStarted(
AuditLogger $auditLogger, AuditLogger $auditLogger,
Tenant $tenant, Tenant $tenant,
@ -820,7 +1111,7 @@ private function auditCompleted(
AuditLogger $auditLogger, AuditLogger $auditLogger,
Tenant $tenant, Tenant $tenant,
BaselineProfile $profile, BaselineProfile $profile,
BaselineSnapshot $snapshot, ?BaselineSnapshot $snapshot,
?User $initiator, ?User $initiator,
BaselineCaptureMode $captureMode, BaselineCaptureMode $captureMode,
int $subjectsTotal, int $subjectsTotal,
@ -828,6 +1119,8 @@ private function auditCompleted(
bool $wasNewSnapshot, bool $wasNewSnapshot,
array $evidenceCaptureStats, array $evidenceCaptureStats,
array $gaps, array $gaps,
bool $currentBaselineChanged,
?string $reasonCode,
): void { ): void {
$auditLogger->log( $auditLogger->log(
tenant: $tenant, tenant: $tenant,
@ -841,8 +1134,10 @@ private function auditCompleted(
'capture_mode' => $captureMode->value, 'capture_mode' => $captureMode->value,
'inventory_sync_run_id' => $inventorySyncRunId, 'inventory_sync_run_id' => $inventorySyncRunId,
'subjects_total' => $subjectsTotal, 'subjects_total' => $subjectsTotal,
'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_id' => $snapshot?->getKey(),
'snapshot_identity_hash' => (string) $snapshot->snapshot_identity_hash, 'snapshot_identity_hash' => $snapshot instanceof BaselineSnapshot ? (string) $snapshot->snapshot_identity_hash : null,
'reason_code' => $reasonCode,
'current_baseline_changed' => $currentBaselineChanged,
'was_new_snapshot' => $wasNewSnapshot, 'was_new_snapshot' => $wasNewSnapshot,
'evidence_capture' => $evidenceCaptureStats, 'evidence_capture' => $evidenceCaptureStats,
'gaps' => $gaps, 'gaps' => $gaps,
@ -878,17 +1173,4 @@ private function mergeGapCounts(array ...$gaps): array
return $merged; return $merged;
} }
private function resolveLatestInventorySyncRun(Tenant $tenant): ?OperationRun
{
$run = OperationRun::query()
->where('tenant_id', (int) $tenant->getKey())
->where('type', OperationRunType::InventorySync->value)
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
return $run instanceof OperationRun ? $run : null;
}
} }

View File

@ -4,7 +4,6 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\OpsUx\ActiveRuns;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -86,13 +85,13 @@ public function refreshRuns(): void
$query = OperationRun::query() $query = OperationRun::query()
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->healthyActive() ->active()
->orderByDesc('created_at'); ->orderByDesc('created_at');
$activeCount = (clone $query)->count(); $activeCount = (clone $query)->count();
$this->runs = (clone $query)->limit(6)->get(); $this->runs = (clone $query)->limit(6)->get();
$this->overflowCount = max(0, $activeCount - 5); $this->overflowCount = max(0, $activeCount - 5);
$this->hasActiveRuns = ActiveRuns::existForTenantId($tenantId); $this->hasActiveRuns = $activeCount > 0;
} }
public function render(): \Illuminate\Contracts\View\View public function render(): \Illuminate\Contracts\View\View

View File

@ -20,21 +20,6 @@ class BaselineProfile extends Model
{ {
use HasFactory; use HasFactory;
/**
* @deprecated Use BaselineProfileStatus::Draft instead.
*/
public const string STATUS_DRAFT = 'draft';
/**
* @deprecated Use BaselineProfileStatus::Active instead.
*/
public const string STATUS_ACTIVE = 'active';
/**
* @deprecated Use BaselineProfileStatus::Archived instead.
*/
public const string STATUS_ARCHIVED = 'archived';
/** @var list<string> */ /** @var list<string> */
protected $fillable = [ protected $fillable = [
'workspace_id', 'workspace_id',

View File

@ -25,12 +25,17 @@ public function toDatabase(object $notifiable): array
{ {
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable); $message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification'); $reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
$baselineTruthChanged = data_get($this->run->context, 'baseline_capture.current_baseline_changed');
if ($reasonEnvelope !== null) { if ($reasonEnvelope !== null) {
$message['reason_translation'] = $reasonEnvelope->toArray(); $message['reason_translation'] = $reasonEnvelope->toArray();
$message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode(); $message['diagnostic_reason_code'] = $reasonEnvelope->diagnosticCode();
} }
if (is_bool($baselineTruthChanged)) {
$message['baseline_truth_changed'] = $baselineTruthChanged;
}
return $message; return $message;
} }
} }

View File

@ -16,6 +16,9 @@
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineScope; use App\Support\Baselines\BaselineScope;
use App\Support\Baselines\BaselineSupportCapabilityGuard; use App\Support\Baselines\BaselineSupportCapabilityGuard;
use App\Support\Inventory\InventoryCoverage;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use InvalidArgumentException; use InvalidArgumentException;
@ -62,6 +65,16 @@ public function startCapture(
]; ];
} }
$truthfulTypes = $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture')['truthful_types'] ?? null;
$inventoryEligibility = $this->latestInventoryEligibilityDecision($sourceTenant, $effectiveScope, is_array($truthfulTypes) ? $truthfulTypes : null);
if (! $inventoryEligibility['ok']) {
return [
'ok' => false,
'reason_code' => $inventoryEligibility['reason_code'],
];
}
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode $captureMode = $profile->capture_mode instanceof BaselineCaptureMode
? $profile->capture_mode ? $profile->capture_mode
: BaselineCaptureMode::Opportunistic; : BaselineCaptureMode::Opportunistic;
@ -75,6 +88,10 @@ public function startCapture(
'source_tenant_id' => (int) $sourceTenant->getKey(), 'source_tenant_id' => (int) $sourceTenant->getKey(),
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'), 'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'capture'),
'capture_mode' => $captureMode->value, 'capture_mode' => $captureMode->value,
'baseline_capture' => [
'inventory_sync_run_id' => $inventoryEligibility['inventory_sync_run_id'],
'eligibility' => $this->eligibilityContextPayload($inventoryEligibility, phase: 'preflight'),
],
]; ];
$run = $this->runs->ensureRunWithIdentity( $run = $this->runs->ensureRunWithIdentity(
@ -114,4 +131,134 @@ private function validatePreconditions(BaselineProfile $profile, Tenant $sourceT
return null; return null;
} }
/**
* @param list<string>|null $truthfulTypes
* @return array{
* ok: bool,
* reason_code: ?string,
* inventory_sync_run_id: ?int,
* inventory_outcome: ?string,
* effective_types: list<string>,
* covered_types: list<string>,
* uncovered_types: list<string>
* }
*/
public function latestInventoryEligibilityDecision(
Tenant $sourceTenant,
BaselineScope $effectiveScope,
?array $truthfulTypes = null,
): array {
$effectiveTypes = is_array($truthfulTypes) && $truthfulTypes !== []
? array_values(array_unique(array_filter($truthfulTypes, 'is_string')))
: $effectiveScope->allTypes();
sort($effectiveTypes, SORT_STRING);
$run = OperationRun::query()
->where('tenant_id', (int) $sourceTenant->getKey())
->where('type', OperationRunType::InventorySync->value)
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
if (! $run instanceof OperationRun) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_MISSING,
'inventory_sync_run_id' => null,
'inventory_outcome' => null,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
$outcome = is_string($run->outcome) ? trim($run->outcome) : null;
if ($outcome === OperationRunOutcome::Blocked->value) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
if ($outcome === OperationRunOutcome::Failed->value) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
$coverage = InventoryCoverage::fromContext($run->context);
$coveredTypes = $coverage instanceof InventoryCoverage
? array_values(array_intersect($effectiveTypes, $coverage->coveredTypes()))
: [];
sort($coveredTypes, SORT_STRING);
$uncoveredTypes = array_values(array_diff($effectiveTypes, $coveredTypes));
sort($uncoveredTypes, SORT_STRING);
if ($coveredTypes === []) {
return [
'ok' => false,
'reason_code' => BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => [],
'uncovered_types' => $effectiveTypes,
];
}
return [
'ok' => true,
'reason_code' => null,
'inventory_sync_run_id' => (int) $run->getKey(),
'inventory_outcome' => $outcome,
'effective_types' => $effectiveTypes,
'covered_types' => $coveredTypes,
'uncovered_types' => $uncoveredTypes,
];
}
/**
* @param array{
* ok: bool,
* reason_code: ?string,
* inventory_sync_run_id: ?int,
* inventory_outcome: ?string,
* effective_types: list<string>,
* covered_types: list<string>,
* uncovered_types: list<string>
* } $decision
* @return array<string, mixed>
*/
public function eligibilityContextPayload(array $decision, string $phase): array
{
return [
'phase' => $phase,
'ok' => (bool) ($decision['ok'] ?? false),
'reason_code' => is_string($decision['reason_code'] ?? null) ? $decision['reason_code'] : null,
'inventory_sync_run_id' => is_numeric($decision['inventory_sync_run_id'] ?? null)
? (int) $decision['inventory_sync_run_id']
: null,
'inventory_outcome' => is_string($decision['inventory_outcome'] ?? null) ? $decision['inventory_outcome'] : null,
'effective_types' => array_values(array_filter((array) ($decision['effective_types'] ?? []), 'is_string')),
'covered_types' => array_values(array_filter((array) ($decision['covered_types'] ?? []), 'is_string')),
'uncovered_types' => array_values(array_filter((array) ($decision['uncovered_types'] ?? []), 'is_string')),
];
}
} }

View File

@ -68,12 +68,27 @@ public function issueQuery(
string $reasonFilter = self::FILTER_ALL, string $reasonFilter = self::FILTER_ALL,
bool $applyOrdering = true, bool $applyOrdering = true,
): Builder { ): Builder {
$visibleTenants = $this->visibleTenants($workspace, $user); return $this->issueQueryForVisibleTenantIds(
$visibleTenantIds = array_map( $workspace,
static fn (Tenant $tenant): int => (int) $tenant->getKey(), $this->visibleTenantIds($workspace, $user),
$visibleTenants, $tenantId,
$reasonFilter,
$applyOrdering,
); );
}
/**
* @param array<int, int> $visibleTenantIds
* @return Builder<Finding>
*/
private function issueQueryForVisibleTenantIds(
Workspace $workspace,
array $visibleTenantIds,
?int $tenantId = null,
string $reasonFilter = self::FILTER_ALL,
bool $applyOrdering = true,
): Builder {
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) { if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
$visibleTenantIds = []; $visibleTenantIds = [];
} elseif ($tenantId !== null) { } elseif ($tenantId !== null) {
@ -155,9 +170,22 @@ function ($join): void {
*/ */
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
{ {
$allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false); return $this->summaryForVisibleTenantIds(
$brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false); $workspace,
$staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false); $this->visibleTenantIds($workspace, $user),
$tenantId,
);
}
/**
* @param array<int, int> $visibleTenantIds
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
*/
public function summaryForVisibleTenantIds(Workspace $workspace, array $visibleTenantIds, ?int $tenantId = null): array
{
$allIssues = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::FILTER_ALL, applyOrdering: false);
$brokenAssignments = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
$staleInProgress = $this->issueQueryForVisibleTenantIds($workspace, $visibleTenantIds, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
return [ return [
'unique_issue_count' => (clone $allIssues)->count(), 'unique_issue_count' => (clone $allIssues)->count(),
@ -166,6 +194,17 @@ public function summary(Workspace $workspace, User $user, ?int $tenantId = null)
]; ];
} }
/**
* @return array<int, int>
*/
public function visibleTenantIds(Workspace $workspace, User $user): array
{
return array_map(
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
$this->visibleTenants($workspace, $user),
);
}
/** /**
* @return array<string, string> * @return array<string, string>
*/ */

View File

@ -29,6 +29,8 @@
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\ReasonTranslation\ReasonTranslator; use App\Support\ReasonTranslation\ReasonTranslator;
use App\Support\Tenants\TenantOperabilityReasonCode; use App\Support\Tenants\TenantOperabilityReasonCode;
use App\Support\Verification\BlockedVerificationReportFactory;
use App\Support\Verification\VerificationReportWriter;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
@ -942,11 +944,23 @@ public function finalizeExecutionLegitimacyBlockedRun(
'context' => $context, 'context' => $context,
]); ]);
return $this->finalizeBlockedRun( $run = $this->finalizeBlockedRun(
run: $run->fresh(), run: $run->fresh(),
reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value, reasonCode: $decision->reasonCode?->value ?? ExecutionDenialReasonCode::ExecutionPrerequisiteInvalid->value,
message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.', message: $decision->reasonCode?->message() ?? 'Operation blocked before queued execution could begin.',
); );
if ($run->type === 'provider.connection.check') {
VerificationReportWriter::write(
run: $run,
checks: BlockedVerificationReportFactory::checks($run),
identity: BlockedVerificationReportFactory::identity($run),
);
$run->refresh();
}
return $run;
} }
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void private function invokeDispatcher(callable $dispatcher, OperationRun $run): void

View File

@ -11,6 +11,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
use App\Support\Operations\ExecutionAuthorityMode; use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
@ -34,6 +35,7 @@ class QueuedExecutionLegitimacyGate
public function __construct( public function __construct(
private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver, private readonly OperationRunCapabilityResolver $operationRunCapabilityResolver,
private readonly CapabilityResolver $capabilityResolver, private readonly CapabilityResolver $capabilityResolver,
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private readonly TenantOperabilityService $tenantOperabilityService, private readonly TenantOperabilityService $tenantOperabilityService,
private readonly WriteGateInterface $writeGate, private readonly WriteGateInterface $writeGate,
) {} ) {}
@ -71,12 +73,8 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled); return QueuedExecutionLegitimacyDecision::deny($context, $checks, ExecutionDenialReasonCode::InitiatorNotEntitled);
} }
if ($context->requiredCapability !== null && $context->tenant instanceof Tenant) { if ($context->requiredCapability !== null) {
$checks['capability'] = $this->capabilityResolver->can( $checks['capability'] = $this->initiatorHasRequiredCapability($context) ? 'passed' : 'failed';
$context->initiator,
$context->tenant,
$context->requiredCapability,
) ? 'passed' : 'failed';
if ($checks['capability'] === 'failed') { if ($checks['capability'] === 'failed') {
return QueuedExecutionLegitimacyDecision::deny( return QueuedExecutionLegitimacyDecision::deny(
@ -106,7 +104,7 @@ public function evaluate(OperationRun $run): QueuedExecutionLegitimacyDecision
tenant: $context->tenant, tenant: $context->tenant,
question: $operabilityQuestion, question: $operabilityQuestion,
workspaceId: $context->workspaceId, workspaceId: $context->workspaceId,
lane: TenantInteractionLane::AdministrativeManagement, lane: $this->laneForContext($context),
); );
$checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed'; $checks['tenant_operability'] = $operability->allowed ? 'passed' : 'failed';
@ -228,6 +226,35 @@ private function resolveProviderConnectionId(array $context): ?int
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null; return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
} }
private function initiatorHasRequiredCapability(QueuedExecutionContext $context): bool
{
if (! $context->initiator instanceof User || ! is_string($context->requiredCapability) || $context->requiredCapability === '') {
return false;
}
if (str_starts_with($context->requiredCapability, 'workspace')) {
if ($context->workspaceId <= 0) {
return false;
}
return $this->workspaceCapabilityResolver->can(
$context->initiator,
$context->run->tenant?->workspace ?? $context->run->workspace()->firstOrFail(),
$context->requiredCapability,
);
}
if (! $context->tenant instanceof Tenant) {
return false;
}
return $this->capabilityResolver->can(
$context->initiator,
$context->tenant,
$context->requiredCapability,
);
}
/** /**
* @return list<string> * @return list<string>
*/ */
@ -262,4 +289,16 @@ private function requiresWriteGate(QueuedExecutionContext $context): bool
{ {
return in_array('write_gate', $context->prerequisiteClasses, true); return in_array('write_gate', $context->prerequisiteClasses, true);
} }
private function laneForContext(QueuedExecutionContext $context): TenantInteractionLane
{
$runContext = is_array($context->run->context) ? $context->run->context : [];
$wizardFlow = data_get($runContext, 'wizard.flow');
if (is_string($wizardFlow) && trim($wizardFlow) === 'managed_tenant_onboarding') {
return TenantInteractionLane::OnboardingWorkflow;
}
return TenantInteractionLane::AdministrativeManagement;
}
} }

View File

@ -38,7 +38,6 @@ final class BadgeCatalog
BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class, BadgeDomain::BooleanEnabled->value => Domains\BooleanEnabledBadge::class,
BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class, BadgeDomain::BooleanHasErrors->value => Domains\BooleanHasErrorsBadge::class,
BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class, BadgeDomain::TenantStatus->value => Domains\TenantStatusBadge::class,
BadgeDomain::TenantAppStatus->value => Domains\TenantAppStatusBadge::class,
BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class, BadgeDomain::TenantRbacStatus->value => Domains\TenantRbacStatusBadge::class,
BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class, BadgeDomain::TenantPermissionStatus->value => Domains\TenantPermissionStatusBadge::class,
BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class, BadgeDomain::PolicySnapshotMode->value => Domains\PolicySnapshotModeBadge::class,

View File

@ -29,7 +29,6 @@ enum BadgeDomain: string
case BooleanEnabled = 'boolean_enabled'; case BooleanEnabled = 'boolean_enabled';
case BooleanHasErrors = 'boolean_has_errors'; case BooleanHasErrors = 'boolean_has_errors';
case TenantStatus = 'tenant_status'; case TenantStatus = 'tenant_status';
case TenantAppStatus = 'tenant_app_status';
case TenantRbacStatus = 'tenant_rbac_status'; case TenantRbacStatus = 'tenant_rbac_status';
case TenantPermissionStatus = 'tenant_permission_status'; case TenantPermissionStatus = 'tenant_permission_status';
case PolicySnapshotMode = 'policy_snapshot_mode'; case PolicySnapshotMode = 'policy_snapshot_mode';

View File

@ -1,24 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class TenantAppStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
'ok' => new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'),
'configured' => new BadgeSpec('Configured', 'success', 'heroicon-m-check-circle'),
'pending' => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'),
'requires_consent', 'consent_required' => new BadgeSpec('Consent required', 'warning', 'heroicon-m-exclamation-triangle'),
'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -14,6 +14,7 @@
use App\Services\Baselines\BaselineSnapshotTruthResolver; use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\CountDescriptor; use App\Support\Ui\OperatorExplanation\CountDescriptor;
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -120,7 +121,8 @@ public static function forTenant(?Tenant $tenant): self
$effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null; $effectiveSnapshot = $truthResolution['effective_snapshot'] ?? null;
$snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null; $snapshotId = $effectiveSnapshot instanceof BaselineSnapshot ? (int) $effectiveSnapshot->getKey() : null;
$snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null; $snapshotReasonCode = is_string($truthResolution['reason_code'] ?? null) ? (string) $truthResolution['reason_code'] : null;
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode); $latestCaptureRun = self::latestBaselineCaptureRun($profile);
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode, $latestCaptureRun);
try { try {
$profileScope = $profile->normalizedScope(); $profileScope = $profile->normalizedScope();
@ -905,8 +907,35 @@ private static function empty(
); );
} }
private static function missingSnapshotMessage(?string $reasonCode): ?string private static function latestBaselineCaptureRun(BaselineProfile $profile): ?OperationRun
{ {
return OperationRun::query()
->where('workspace_id', (int) $profile->workspace_id)
->where('type', OperationRunType::BaselineCapture->value)
->where('context->baseline_profile_id', (int) $profile->getKey())
->where('status', OperationRunStatus::Completed->value)
->orderByDesc('completed_at')
->orderByDesc('id')
->first();
}
private static function missingSnapshotMessage(?string $reasonCode, ?OperationRun $latestCaptureRun = null): ?string
{
$latestCaptureEnvelope = $latestCaptureRun instanceof OperationRun
? app(ReasonPresenter::class)->forOperationRun($latestCaptureRun, 'artifact_truth')
: null;
if ($latestCaptureEnvelope !== null
&& in_array($latestCaptureEnvelope->internalCode, [
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED,
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE,
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
], true)
&& trim($latestCaptureEnvelope->shortExplanation) !== '') {
return $latestCaptureEnvelope->shortExplanation;
}
return match ($reasonCode) { return match ($reasonCode) {
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.', BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare becomes available after a complete snapshot is finalized.',
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.', BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete and there is no current complete snapshot to compare against.',

View File

@ -22,6 +22,16 @@ final class BaselineReasonCodes
public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope'; public const string CAPTURE_UNSUPPORTED_SCOPE = 'baseline.capture.unsupported_scope';
public const string CAPTURE_INVENTORY_MISSING = 'baseline.capture.inventory_missing';
public const string CAPTURE_INVENTORY_BLOCKED = 'baseline.capture.inventory_blocked';
public const string CAPTURE_INVENTORY_FAILED = 'baseline.capture.inventory_failed';
public const string CAPTURE_UNUSABLE_COVERAGE = 'baseline.capture.unusable_coverage';
public const string CAPTURE_ZERO_SUBJECTS = 'baseline.capture.zero_subjects';
public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building'; public const string SNAPSHOT_BUILDING = 'baseline.snapshot.building';
public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete'; public const string SNAPSHOT_INCOMPLETE = 'baseline.snapshot.incomplete';
@ -73,6 +83,11 @@ public static function all(): array
self::CAPTURE_ROLLOUT_DISABLED, self::CAPTURE_ROLLOUT_DISABLED,
self::CAPTURE_INVALID_SCOPE, self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE, self::CAPTURE_UNSUPPORTED_SCOPE,
self::CAPTURE_INVENTORY_MISSING,
self::CAPTURE_INVENTORY_BLOCKED,
self::CAPTURE_INVENTORY_FAILED,
self::CAPTURE_UNUSABLE_COVERAGE,
self::CAPTURE_ZERO_SUBJECTS,
self::SNAPSHOT_BUILDING, self::SNAPSHOT_BUILDING,
self::SNAPSHOT_INCOMPLETE, self::SNAPSHOT_INCOMPLETE,
self::SNAPSHOT_SUPERSEDED, self::SNAPSHOT_SUPERSEDED,
@ -128,7 +143,12 @@ public static function trustImpact(?string $reasonCode): ?string
self::CAPTURE_MISSING_SOURCE_TENANT, self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE, self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_INVALID_SCOPE, self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE => 'unusable', self::CAPTURE_UNSUPPORTED_SCOPE,
self::CAPTURE_INVENTORY_MISSING,
self::CAPTURE_INVENTORY_BLOCKED,
self::CAPTURE_INVENTORY_FAILED,
self::CAPTURE_UNUSABLE_COVERAGE,
self::CAPTURE_ZERO_SUBJECTS => 'unusable',
default => null, default => null,
}; };
} }
@ -148,6 +168,10 @@ public static function absencePattern(?string $reasonCode): ?string
self::CAPTURE_MISSING_SOURCE_TENANT, self::CAPTURE_MISSING_SOURCE_TENANT,
self::CAPTURE_PROFILE_NOT_ACTIVE, self::CAPTURE_PROFILE_NOT_ACTIVE,
self::CAPTURE_ROLLOUT_DISABLED, self::CAPTURE_ROLLOUT_DISABLED,
self::CAPTURE_INVENTORY_MISSING,
self::CAPTURE_INVENTORY_BLOCKED,
self::CAPTURE_INVENTORY_FAILED,
self::CAPTURE_UNUSABLE_COVERAGE,
self::COMPARE_NO_ASSIGNMENT, self::COMPARE_NO_ASSIGNMENT,
self::COMPARE_PROFILE_NOT_ACTIVE, self::COMPARE_PROFILE_NOT_ACTIVE,
self::COMPARE_NO_ELIGIBLE_TARGET, self::COMPARE_NO_ELIGIBLE_TARGET,
@ -159,6 +183,7 @@ public static function absencePattern(?string $reasonCode): ?string
self::SNAPSHOT_SUPERSEDED, self::SNAPSHOT_SUPERSEDED,
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite', self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
self::SNAPSHOT_CAPTURE_FAILED => 'unavailable', self::SNAPSHOT_CAPTURE_FAILED => 'unavailable',
self::CAPTURE_ZERO_SUBJECTS => 'missing_input',
self::CAPTURE_INVALID_SCOPE, self::CAPTURE_INVALID_SCOPE,
self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable', self::CAPTURE_UNSUPPORTED_SCOPE => 'unavailable',
default => null, default => null,

View File

@ -8,6 +8,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
@ -141,9 +142,37 @@ private function baselineCaptureHeadline(
array $counts, array $counts,
?OperatorExplanationPattern $operatorExplanation, ?OperatorExplanationPattern $operatorExplanation,
): string { ): string {
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$resumeToken = data_get($context, 'baseline_capture.resume_token'); $resumeToken = data_get($context, 'baseline_capture.resume_token');
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
return 'The baseline capture could not continue because no current inventory basis was available.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
return $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync changed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync was blocked.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
return $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory sync failed after the run was queued.'
: 'The baseline capture was blocked because the latest inventory sync failed.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
return $changedAfterEnqueue
? 'The baseline capture stopped because the latest inventory coverage became unusable after the run was queued.'
: 'The baseline capture could not produce a usable baseline because the latest inventory coverage was not credible.';
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
return 'The baseline capture finished without a usable baseline because no governed subjects were in scope.';
}
if ($subjectsTotal === 0) { if ($subjectsTotal === 0) {
return 'No baseline was captured because no governed subjects were ready.'; return 'No baseline was captured because no governed subjects were ready.';
@ -629,9 +658,55 @@ private function pushCandidate(array &$candidates, ?string $code, ?string $label
*/ */
private function baselineCaptureCandidates(array &$candidates, array $context): void private function baselineCaptureCandidates(array &$candidates, array $context): void
{ {
$reasonCode = (string) data_get($context, 'baseline_capture.reason_code', data_get($context, 'reason_code', ''));
$subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total')); $subjectsTotal = $this->intValue(data_get($context, 'baseline_capture.subjects_total'));
$gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count')); $gapCount = $this->intValue(data_get($context, 'baseline_capture.gaps.count'));
$resumeToken = data_get($context, 'baseline_capture.resume_token'); $resumeToken = data_get($context, 'baseline_capture.resume_token');
$changedAfterEnqueue = data_get($context, 'baseline_capture.eligibility.changed_after_enqueue') === true;
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_MISSING) {
$this->pushCandidate($candidates, $reasonCode, 'Run tenant sync first', 'No current inventory basis was available for this baseline capture.', 95);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED) {
$this->pushCandidate(
$candidates,
$reasonCode,
'Latest inventory sync was blocked',
$changedAfterEnqueue
? 'The latest inventory sync changed after the run was queued and blocked the capture.'
: 'The latest inventory sync was blocked before this capture could produce a trustworthy baseline.',
95,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_INVENTORY_FAILED) {
$this->pushCandidate(
$candidates,
$reasonCode,
'Latest inventory sync failed',
$changedAfterEnqueue
? 'The latest inventory sync failed after the run was queued, so the capture stopped without refreshing baseline truth.'
: 'The latest inventory sync failed before this capture could produce a trustworthy baseline.',
95,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE) {
$this->pushCandidate(
$candidates,
$reasonCode,
'Latest inventory coverage unusable',
$changedAfterEnqueue
? 'The latest inventory coverage became unusable after the run was queued, so the capture stopped without refreshing baseline truth.'
: 'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
95,
);
}
if ($reasonCode === BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS) {
$this->pushCandidate($candidates, $reasonCode, 'No subjects were in scope', 'No governed subjects were available for this baseline capture.', 95);
}
if ($subjectsTotal === 0) { if ($subjectsTotal === 0) {
$this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95); $this->pushCandidate($candidates, 'no_subjects_in_scope', 'No governed subjects captured', 'No governed subjects were available for this baseline capture.', 95);

View File

@ -547,6 +547,11 @@ private static function terminalSupportingLines(OperationRun $run): array
$lines[] = $guidance; $lines[] = $guidance;
} }
$baselineTruthChange = self::baselineTruthChangeLine($run);
if ($baselineTruthChange !== null) {
$lines[] = $baselineTruthChange;
}
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []); $summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
if ($summary !== null) { if ($summary !== null) {
$lines[] = $summary; $lines[] = $summary;
@ -560,6 +565,25 @@ private static function terminalSupportingLines(OperationRun $run): array
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== '')); return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
} }
private static function baselineTruthChangeLine(OperationRun $run): ?string
{
if ((string) $run->type !== 'baseline_capture') {
return null;
}
$changed = data_get($run->context, 'baseline_capture.current_baseline_changed');
if ($changed === true) {
return 'Current baseline truth was updated.';
}
if ($changed === false) {
return 'Current baseline truth was unchanged.';
}
return null;
}
/** /**
* @return array{label:string, url:?string, target:string} * @return array{label:string, url:?string, target:string}
*/ */

View File

@ -44,6 +44,7 @@ public function forOperationRun(OperationRun $run, string $surface = 'detail'):
$contextReasonCode = data_get($context, 'execution_legitimacy.reason_code') $contextReasonCode = data_get($context, 'execution_legitimacy.reason_code')
?? data_get($context, 'reason_code') ?? data_get($context, 'reason_code')
?? data_get($context, 'baseline_capture.reason_code')
?? data_get($context, 'baseline_compare.reason_code'); ?? data_get($context, 'baseline_compare.reason_code');
if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') { if (is_string($contextReasonCode) && trim($contextReasonCode) !== '') {

View File

@ -51,8 +51,8 @@ public function translate(
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), $artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode), $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode), $artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => $this->translateBaselineReason($reasonCode, $context),
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT, $artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context), $artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => ExecutionDenialReasonCode::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context), $artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => LifecycleReconciliationReason::tryFrom($reasonCode)?->toReasonResolutionEnvelope($surface, $context),
@ -116,7 +116,10 @@ private function fallbackTranslate(
return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context); return $this->fallbackReasonTranslator->translate($reasonCode, $surface, $context);
} }
private function translateBaselineReason(string $reasonCode): ReasonResolutionEnvelope /**
* @param array<string, mixed> $context
*/
private function translateBaselineReason(string $reasonCode, array $context = []): ReasonResolutionEnvelope
{ {
[$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) { [$operatorLabel, $shortExplanation, $actionability, $nextStep] = match ($reasonCode) {
BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [ BaselineReasonCodes::CAPTURE_MISSING_SOURCE_TENANT => [
@ -138,6 +141,51 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
'prerequisite_missing', 'prerequisite_missing',
'Enable the rollout before retrying full-content baseline work.', 'Enable the rollout before retrying full-content baseline work.',
], ],
BaselineReasonCodes::CAPTURE_INVENTORY_MISSING => [
'Run tenant sync first',
$this->baselineCaptureTruthImpactExplanation(
'No current inventory basis was available for this baseline capture.',
$context,
),
'prerequisite_missing',
'Run inventory sync for this tenant, then capture the baseline again.',
],
BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED => [
'Latest inventory sync was blocked',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory sync was blocked, so this capture could not use a credible upstream basis.',
$context,
),
'prerequisite_missing',
'Review the blocked inventory sync, fix the prerequisite, and rerun sync before capturing again.',
],
BaselineReasonCodes::CAPTURE_INVENTORY_FAILED => [
'Latest inventory sync failed',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory sync failed, so this capture could not use a credible upstream basis.',
$context,
),
'prerequisite_missing',
'Review the failed inventory sync, fix the error, and rerun sync before capturing again.',
],
BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE => [
'Latest inventory coverage unusable',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory sync did not produce usable governed-subject coverage for this baseline capture.',
$context,
),
'prerequisite_missing',
'Run inventory sync until the governed subject types show current coverage, then capture again.',
],
BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS => [
'No subjects were in scope',
$this->baselineCaptureTruthImpactExplanation(
'The latest inventory basis was credible, but no governed subjects were in scope for this baseline capture.',
$context,
),
'prerequisite_missing',
'Review the baseline scope and tenant inventory, then capture again when governed subjects are available.',
],
BaselineReasonCodes::SNAPSHOT_BUILDING, BaselineReasonCodes::SNAPSHOT_BUILDING,
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [ BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => [
'Baseline still building', 'Baseline still building',
@ -242,6 +290,29 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
); );
} }
/**
* @param array<string, mixed> $context
*/
private function baselineCaptureTruthImpactExplanation(string $baseExplanation, array $context): string
{
$changed = data_get($context, 'baseline_capture.current_baseline_changed');
$previousSnapshotExists = data_get($context, 'baseline_capture.previous_current_snapshot_exists');
if ($changed === true) {
return $baseExplanation.' TenantPilot updated the current baseline truth with a new consumable snapshot.';
}
if ($previousSnapshotExists === true) {
return $baseExplanation.' TenantPilot kept the last trustworthy baseline in place.';
}
if ($previousSnapshotExists === false) {
return $baseExplanation.' No current trustworthy baseline is available yet.';
}
return $baseExplanation;
}
private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope private function translateBaselineCompareReason(string $reasonCode): ReasonResolutionEnvelope
{ {
$enum = BaselineCompareReasonCode::tryFrom($reasonCode); $enum = BaselineCompareReasonCode::tryFrom($reasonCode);

View File

@ -71,6 +71,7 @@ public function build(Workspace $workspace, User $user): array
->all(); ->all();
$this->capabilityResolver->primeMemberships($user, $accessibleTenantIds); $this->capabilityResolver->primeMemberships($user, $accessibleTenantIds);
$visibleFindingsTenantIds = $this->visibleFindingTenantIds($accessibleTenants, $user);
$canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW); $canViewAlerts = $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::ALERTS_VIEW);
$navigationContext = $this->workspaceOverviewNavigationContext(); $navigationContext = $this->workspaceOverviewNavigationContext();
@ -136,8 +137,8 @@ public function build(Workspace $workspace, User $user): array
'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'), 'action_url' => $calmness['next_action']['url'] ?? ChooseTenant::getUrl(panel: 'admin'),
]; ];
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user); $myFindingsSignal = $this->myFindingsSignal($workspaceId, $visibleFindingsTenantIds, $user);
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user); $findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $visibleFindingsTenantIds);
$zeroTenantState = null; $zeroTenantState = null;
@ -210,18 +211,11 @@ private function accessibleTenants(Workspace $workspace, User $user): Collection
} }
/** /**
* @param Collection<int, Tenant> $accessibleTenants * @param array<int, int> $visibleTenantIds
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function myFindingsSignal(int $workspaceId, Collection $accessibleTenants, User $user): array private function myFindingsSignal(int $workspaceId, array $visibleTenantIds, User $user): array
{ {
$visibleTenantIds = $accessibleTenants
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
$assignedCounts = $visibleTenantIds === [] $assignedCounts = $visibleTenantIds === []
? null ? null
: $this->scopeToVisibleTenants( : $this->scopeToVisibleTenants(
@ -271,9 +265,9 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function findingsHygieneSignal(Workspace $workspace, User $user): array private function findingsHygieneSignal(Workspace $workspace, array $visibleTenantIds): array
{ {
$summary = $this->findingAssignmentHygieneService->summary($workspace, $user); $summary = $this->findingAssignmentHygieneService->summaryForVisibleTenantIds($workspace, $visibleTenantIds);
$uniqueIssueCount = $summary['unique_issue_count']; $uniqueIssueCount = $summary['unique_issue_count'];
$brokenAssignmentCount = $summary['broken_assignment_count']; $brokenAssignmentCount = $summary['broken_assignment_count'];
$staleInProgressCount = $summary['stale_in_progress_count']; $staleInProgressCount = $summary['stale_in_progress_count'];
@ -297,6 +291,20 @@ private function findingsHygieneSignal(Workspace $workspace, User $user): array
]; ];
} }
/**
* @param Collection<int, Tenant> $accessibleTenants
* @return array<int, int>
*/
private function visibleFindingTenantIds(Collection $accessibleTenants, User $user): array
{
return $accessibleTenants
->filter(fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW))
->pluck('id')
->map(static fn (mixed $id): int => (int) $id)
->values()
->all();
}
private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string
{ {
if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) { if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) {

View File

@ -42,7 +42,6 @@ public function definition(): array
'app_client_id' => fake()->uuid(), 'app_client_id' => fake()->uuid(),
'app_client_secret' => null, // Skip encryption in tests 'app_client_secret' => null, // Skip encryption in tests
'app_certificate_thumbprint' => null, 'app_certificate_thumbprint' => null,
'app_status' => 'ok',
'app_notes' => null, 'app_notes' => null,
'status' => 'active', 'status' => 'active',
'environment' => 'other', 'environment' => 'other',

View File

@ -156,7 +156,9 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0, 'was_empty_capture' => ($expectedItems ?? $producerExpectedItems ?? $producerSubjectsTotal) === 0 && $persistedItems === 0,
]; ];
if ($expectedItems !== null && $expectedItems === $persistedItems) { if ($expectedItems !== null
&& $expectedItems === $persistedItems
&& ! ($expectedItems === 0 && $persistedItems === 0)) {
return [ return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value, 'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at, 'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
@ -167,7 +169,10 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
]; ];
} }
if ($producerSucceeded && $producerExpectedItems !== null && $producerExpectedItems === $persistedItems) { if ($producerSucceeded
&& $producerExpectedItems !== null
&& $producerExpectedItems === $persistedItems
&& ! ($producerExpectedItems === 0 && $persistedItems === 0)) {
return [ return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value, 'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at, 'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at,
@ -184,11 +189,11 @@ private function classifyLegacySnapshot(object $row, array $summary, int $persis
$producerSubjectsTotal, $producerSubjectsTotal,
], static fn (?int $value): bool => $value !== null), true)) { ], static fn (?int $value): bool => $value !== null), true)) {
return [ return [
'lifecycle_state' => BaselineSnapshotLifecycleState::Complete->value, 'lifecycle_state' => BaselineSnapshotLifecycleState::Incomplete->value,
'completed_at' => $producerRun->completed_at ?? $row->captured_at ?? $row->created_at ?? $row->updated_at, 'completed_at' => null,
'failed_at' => null, 'failed_at' => $producerRun->completed_at ?? $row->updated_at ?? $row->captured_at ?? $row->created_at,
'completion_meta' => $completionMeta + [ 'completion_meta' => $completionMeta + [
'finalization_reason_code' => 'baseline.snapshot.legacy_empty_capture_proof', 'finalization_reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
], ],
]; ];
} }

View File

@ -20,7 +20,7 @@
<p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p> <p><strong>Tenant:</strong> {{ $tenant->name }} ({{ $tenant->graphTenantId() }})</p>
@isset($connection) @isset($connection)
<p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p> <p><strong>Connection:</strong> {{ $connection->connection_type->value === 'platform' ? 'Platform connection' : 'Dedicated connection' }}</p>
<p><strong>Verification state:</strong> {{ ucfirst($connection->verification_status->value) }}</p> <p><strong>Verification state:</strong> {{ $verificationStateLabel ?? ucfirst($connection->verification_status->value) }}</p>
@endisset @endisset
<p> <p>
<span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}"> <span class="status {{ $status === 'ok' ? 'ok' : ($status === 'consent_denied' ? 'warning' : 'error') }}">

View File

@ -1,6 +1,8 @@
@php($runs = $runs ?? collect()) @php
@php($overflowCount = (int) ($overflowCount ?? 0)) $runs = $runs ?? collect();
@php($tenant = $tenant ?? null) $overflowCount = (int) ($overflowCount ?? 0);
$tenant = $tenant ?? null;
@endphp
{{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}} {{-- Cleanup is delegated to the shared poller helper, which uses teardownObserver and new MutationObserver. --}}
@ -16,6 +18,17 @@
@if($runs->isNotEmpty()) @if($runs->isNotEmpty())
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;"> <div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
@foreach ($runs->take(5) as $run) @foreach ($runs->take(5) as $run)
@php
$statusSpec = \App\Support\Badges\BadgeRenderer::spec(
\App\Support\Badges\BadgeDomain::OperationRunStatus,
[
'status' => (string) $run->status,
'freshness_state' => $run->freshnessState()->value,
],
);
$lifecycleAttention = \App\Support\OpsUx\OperationUxPresenter::lifecycleAttentionSummary($run);
$guidance = \App\Support\OpsUx\OperationUxPresenter::surfaceGuidance($run);
@endphp
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300" <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
wire:key="run-{{ $run->id }}"> wire:key="run-{{ $run->id }}">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
@ -30,6 +43,21 @@
Running {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }} Running {{ ($run->started_at ?? $run->created_at)?->diffForHumans(null, true, true) }}
@endif @endif
</p> </p>
<div class="mt-2 flex flex-wrap items-center gap-2">
<x-filament::badge :color="$statusSpec->color" size="sm">
{{ $statusSpec->label }}
</x-filament::badge>
@if ($lifecycleAttention)
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-800 dark:border-warning-600/40 dark:bg-warning-500/10 dark:text-warning-100">
{{ $lifecycleAttention }}
</span>
@endif
</div>
@if ($guidance)
<p class="mt-2 text-xs leading-5 text-gray-600 dark:text-gray-300">
{{ $guidance }}
</p>
@endif
</div> </div>
@if ($tenant) @if ($tenant)

View File

@ -54,7 +54,7 @@
]); ]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$operationsIndexUrl = route('admin.operations.index'); $operationsIndexUrl = OperationRunLinks::index($tenant);
$page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin')); $page = visit(TenantResource::getUrl('view', ['record' => $tenant->getRouteKey()], panel: 'admin'));

View File

@ -2,7 +2,9 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\OperationRun;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -20,6 +22,8 @@
])); ]));
$response->assertOk(); $response->assertOk();
$response->assertSeeText('Verification state:');
$response->assertSeeText('Needs verification');
$response->assertSee( $response->assertSee(
route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]), route('filament.admin.resources.tenants.view', ['tenant' => $tenant->external_id, 'record' => $tenant]),
false, false,
@ -60,6 +64,57 @@
$response->assertSee(route('admin.onboarding'), false); $response->assertSee(route('admin.onboarding'), false);
}); });
it('invalidates resumable onboarding verification state for the same platform connection after a successful callback', function () {
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-verify-reset',
'name' => 'Reset Tenant',
'status' => Tenant::STATUS_ONBOARDING,
]);
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => $tenant->graphTenantId(),
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.connection.check',
]);
$draft = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => (string) $tenant->tenant_id,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
'verification_run_id' => (int) $run->getKey(),
'bootstrap_operation_runs' => [123, 456],
'bootstrap_operation_types' => ['inventory_sync'],
'bootstrap_run_ids' => [123, 456],
],
]);
$this->get(route('admin.consent.callback', [
'tenant' => $tenant->tenant_id,
'admin_consent' => 'true',
]))->assertOk();
$draft->refresh();
expect($draft->state['verification_operation_run_id'] ?? null)->toBeNull()
->and($draft->state['verification_run_id'] ?? null)->toBeNull()
->and($draft->state['bootstrap_operation_runs'] ?? null)->toBeNull()
->and($draft->state['bootstrap_operation_types'] ?? null)->toBeNull()
->and($draft->state['bootstrap_run_ids'] ?? null)->toBeNull()
->and($draft->state['connection_recently_updated'] ?? null)->toBeTrue();
});
it('creates tenant and provider connection when callback tenant does not exist', function () { it('creates tenant and provider connection when callback tenant does not exist', function () {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
@ -101,6 +156,8 @@
])); ]));
$response->assertOk(); $response->assertOk();
$response->assertSeeText('Verification state:');
$response->assertSeeText('Not verified');
$connection = ProviderConnection::query() $connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())

View File

@ -15,6 +15,7 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
@ -135,6 +136,35 @@
->assertSee('Ambiguous matches'); ->assertSee('Ambiguous matches');
}); });
it('allows entitled viewers to open blocked baseline-capture run detail surfaces', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'current_baseline_changed' => false,
],
],
'completed_at' => now(),
]);
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Latest inventory sync failed');
});
it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void { it('keeps governance summary surfaces deny-as-not-found for workspace members without tenant entitlement', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([

View File

@ -18,6 +18,9 @@
'capture_mode' => BaselineCaptureMode::Opportunistic->value, 'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -26,6 +29,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Audit Policy A', 'display_name' => 'Audit Policy A',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E_AUDIT'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);

View File

@ -34,12 +34,9 @@
'display_name' => 'Isolated Policy', 'display_name' => 'Isolated Policy',
]); ]);
$lastSeenRun = OperationRun::factory()->create([ $lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
'tenant_id' => (int) $tenant->getKey(), 'deviceConfiguration' => 'succeeded',
'workspace_id' => (int) $tenant->workspace_id, ], attributes: [
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(), 'completed_at' => now(),
]); ]);

View File

@ -20,6 +20,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -41,6 +44,7 @@
'assignment_target_count' => 1, 'assignment_target_count' => 1,
], ],
'last_seen_at' => now()->subHour(), 'last_seen_at' => now()->subHour(),
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$snapshotPayload = [ $snapshotPayload = [

View File

@ -29,6 +29,9 @@
'capture_mode' => BaselineCaptureMode::FullContent->value, 'capture_mode' => BaselineCaptureMode::FullContent->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$policy = Policy::factory()->create([ $policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -50,6 +53,7 @@
'assignment_target_count' => 1, 'assignment_target_count' => 1,
], ],
'last_seen_at' => now()->subHour(), 'last_seen_at' => now()->subHour(),
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0); expect(PolicyVersion::query()->where('policy_id', (int) $policy->getKey())->count())->toBe(0);

View File

@ -34,12 +34,9 @@
'display_name' => 'Policy Capture Meta', 'display_name' => 'Policy Capture Meta',
]); ]);
$lastSeenRun = OperationRun::factory()->create([ $lastSeenRun = createInventorySyncOperationRunWithCoverage($tenant, [
'tenant_id' => (int) $tenant->getKey(), 'deviceConfiguration' => 'succeeded',
'workspace_id' => (int) $tenant->workspace_id, ], attributes: [
'type' => OperationRunType::InventorySync->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'completed_at' => now(), 'completed_at' => now(),
]); ]);

View File

@ -6,6 +6,7 @@
use App\Models\BaselineSnapshotItem; use App\Models\BaselineSnapshotItem;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\Baselines\BaselineCaptureService; use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\InventoryMetaContract; use App\Services\Baselines\InventoryMetaContract;
@ -18,6 +19,28 @@
use App\Support\Baselines\BaselineSubjectKey; use App\Support\Baselines\BaselineSubjectKey;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
function createBaselineCaptureInventoryBasis(
Tenant $tenant,
array $statusByType,
array $attributes = [],
): OperationRun {
return createInventorySyncOperationRunWithCoverage($tenant, $statusByType, [], $attributes);
}
function runBaselineCaptureJob(
OperationRun $run,
?OperationRunService $operationRunService = null,
): void {
$operationRunService ??= app(OperationRunService::class);
(new CaptureBaselineSnapshotJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$operationRunService,
);
}
// --- T031: Capture enqueue + precondition tests --- // --- T031: Capture enqueue + precondition tests ---
it('enqueues capture for an active profile and creates an operation run', function () { it('enqueues capture for an active profile and creates an operation run', function () {
@ -29,6 +52,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
/** @var BaselineCaptureService $service */ /** @var BaselineCaptureService $service */
$service = app(BaselineCaptureService::class); $service = app(BaselineCaptureService::class);
@ -53,10 +79,119 @@
expect($effectiveScope['foundation_types'])->toBe([]); expect($effectiveScope['foundation_types'])->toBe([]);
expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']); expect($effectiveScope['all_types'])->toBe(['deviceConfiguration']);
expect($effectiveScope['foundations_included'])->toBeFalse(); expect($effectiveScope['foundations_included'])->toBeFalse();
expect(data_get($context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
expect(data_get($context, 'baseline_capture.eligibility.phase'))->toBe('preflight');
expect(data_get($context, 'baseline_capture.eligibility.ok'))->toBeTrue();
expect(data_get($context, 'baseline_capture.eligibility.covered_types'))->toBe(['deviceConfiguration']);
expect(data_get($context, 'baseline_capture.eligibility.uncovered_types'))->toBe([]);
Queue::assertPushed(CaptureBaselineSnapshotJob::class); Queue::assertPushed(CaptureBaselineSnapshotJob::class);
}); });
it('rejects capture when no current inventory sync exists', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_MISSING);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture when the latest inventory sync was blocked', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
], [
'completed_at' => now()->subMinute(),
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'failed',
], [
'outcome' => 'blocked',
'completed_at' => now(),
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_BLOCKED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture when the latest inventory sync failed without falling back to an older success', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
], [
'completed_at' => now()->subMinute(),
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'failed',
], [
'outcome' => 'failed',
'completed_at' => now(),
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture when the latest inventory coverage is unusable for the baseline scope', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceCompliancePolicy' => 'succeeded',
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeFalse();
expect($result['reason_code'])->toBe(BaselineReasonCodes::CAPTURE_UNUSABLE_COVERAGE);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
});
it('rejects capture for a draft profile with reason code', function () { it('rejects capture for a draft profile with reason code', function () {
Queue::fake(); Queue::fake();
@ -126,6 +261,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$service = app(BaselineCaptureService::class); $service = app(BaselineCaptureService::class);
@ -148,6 +286,9 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$inventoryA = InventoryItem::factory()->create([ $inventoryA = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -156,6 +297,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A', 'display_name' => 'Policy A',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$inventoryB = InventoryItem::factory()->create([ $inventoryB = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -164,6 +306,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy B', 'display_name' => 'Policy B',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E2'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$inventoryC = InventoryItem::factory()->create([ $inventoryC = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
@ -172,6 +315,7 @@
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy C', 'display_name' => 'Policy C',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E3'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -187,13 +331,7 @@
initiator: $user, initiator: $user,
); );
$job = new CaptureBaselineSnapshotJob($run); runBaselineCaptureJob($run, $opService);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
$run->refresh(); $run->refresh();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
@ -269,6 +407,14 @@
expect(data_get($meta, 'meta_contract'))->toBeNull(); expect(data_get($meta, 'meta_contract'))->toBeNull();
} }
expect(data_get($run->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
expect(data_get($run->context, 'baseline_capture.eligibility.phase'))->toBe('runtime_recheck');
expect(data_get($run->context, 'baseline_capture.eligibility.ok'))->toBeTrue();
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeFalse();
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeTrue();
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeFalse();
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeTrue();
$profile->refresh(); $profile->refresh();
expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey()); expect($profile->active_snapshot_id)->toBe((int) $snapshot->getKey());
}); });
@ -311,12 +457,16 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]);
InventoryItem::factory()->count(2)->create([ InventoryItem::factory()->count(2)->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'stable_field' => 'value'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -336,8 +486,7 @@
initiator: $user, initiator: $user,
); );
$job1 = new CaptureBaselineSnapshotJob($run1); (new CaptureBaselineSnapshotJob($run1))->handle($idService, $metaContract, $auditLogger, $opService);
$job1->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterFirst = BaselineSnapshot::query() $snapshotCountAfterFirst = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey()) ->where('baseline_profile_id', $profile->getKey())
@ -361,8 +510,7 @@
], ],
]); ]);
$job2 = new CaptureBaselineSnapshotJob($run2); (new CaptureBaselineSnapshotJob($run2))->handle($idService, $metaContract, $auditLogger, $opService);
$job2->handle($idService, $metaContract, $auditLogger, $opService);
$snapshotCountAfterSecond = BaselineSnapshot::query() $snapshotCountAfterSecond = BaselineSnapshot::query()
->where('baseline_profile_id', $profile->getKey()) ->where('baseline_profile_id', $profile->getKey())
@ -371,14 +519,68 @@
expect($snapshotCountAfterSecond)->toBe(1); expect($snapshotCountAfterSecond)->toBe(1);
}); });
// --- EC-005: Empty scope produces empty snapshot without errors --- it('blocks a queued capture when the latest inventory basis fails after enqueue and keeps the prior current baseline', function () {
it('captures an empty snapshot when no inventory items match the scope', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$previousSnapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $previousSnapshot->getKey()]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
], [
'completed_at' => now()->subMinute(),
]);
Queue::fake();
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue();
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'failed',
], [
'outcome' => 'failed',
'completed_at' => now(),
]);
/** @var OperationRun $run */
$run = $result['run'];
runBaselineCaptureJob($run);
$run->refresh();
$profile->refresh();
expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('blocked');
expect($profile->active_snapshot_id)->toBe((int) $previousSnapshot->getKey());
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED);
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
expect(data_get($run->context, 'baseline_capture.previous_current_snapshot_exists'))->toBeTrue();
expect(data_get($run->context, 'baseline_capture.eligibility.changed_after_enqueue'))->toBeTrue();
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
});
// --- EC-005: Zero-subject captures stay visible but non-authoritative ---
it('records a zero-subject capture as partially succeeded with a non-consumable snapshot', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
createBaselineCaptureInventoryBasis($tenant, [
'deviceConfiguration' => 'succeeded',
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -389,22 +591,22 @@
context: [ context: [
'baseline_profile_id' => (int) $profile->getKey(), 'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(), 'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['nonExistentPolicyType'], 'foundation_types' => []], 'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
], ],
initiator: $user, initiator: $user,
); );
$job = new CaptureBaselineSnapshotJob($run); runBaselineCaptureJob($run, $opService);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
$run->refresh(); $run->refresh();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded'); expect($run->outcome)->toBe('partially_succeeded');
expect(data_get($run->context, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
expect(data_get($run->context, 'baseline_capture.reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
expect(data_get($run->context, 'baseline_capture.subjects_total'))->toBe(0);
expect(data_get($run->context, 'baseline_capture.current_baseline_changed'))->toBeFalse();
expect(data_get($run->context, 'result.current_baseline_changed'))->toBeFalse();
expect(data_get($run->context, 'result.snapshot_lifecycle'))->toBe(BaselineSnapshotLifecycleState::Incomplete->value);
$counts = is_array($run->summary_counts) ? $run->summary_counts : []; $counts = is_array($run->summary_counts) ? $run->summary_counts : [];
expect((int) ($counts['total'] ?? 0))->toBe(0); expect((int) ($counts['total'] ?? 0))->toBe(0);
@ -415,7 +617,12 @@
->first(); ->first();
expect($snapshot)->not->toBeNull(); expect($snapshot)->not->toBeNull();
expect($snapshot?->lifecycleState())->toBe(BaselineSnapshotLifecycleState::Incomplete);
expect(data_get($snapshot?->completion_meta_jsonb ?? [], 'finalization_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0); expect(BaselineSnapshotItem::query()->where('baseline_snapshot_id', $snapshot->getKey())->count())->toBe(0);
$profile->refresh();
expect($profile->active_snapshot_id)->toBeNull();
}); });
it('captures all inventory items when scope has empty policy_types (all types)', function () { it('captures all inventory items when scope has empty policy_types (all types)', function () {
@ -425,17 +632,23 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => [], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createBaselineCaptureInventoryBasis($tenant, [
'deviceCompliancePolicy' => 'succeeded',
'deviceConfiguration' => 'succeeded',
]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'deviceCompliancePolicy', 'policy_type' => 'deviceCompliancePolicy',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
// Foundation types are excluded by default (unless foundation_types is selected). // Foundation types are excluded by default (unless foundation_types is selected).
@ -443,6 +656,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'policy_type' => 'assignmentFilter', 'policy_type' => 'assignmentFilter',
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
]); ]);
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -458,13 +672,7 @@
initiator: $user, initiator: $user,
); );
$job = new CaptureBaselineSnapshotJob($run); runBaselineCaptureJob($run, $opService);
$job->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$opService,
);
$run->refresh(); $run->refresh();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');

View File

@ -1335,12 +1335,19 @@
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::factory()->create([ InventoryItem::factory()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'external_id' => 'policy-a', 'external_id' => 'policy-a',
'policy_type' => 'deviceConfiguration', 'policy_type' => 'deviceConfiguration',
'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'], 'meta_jsonb' => ['odata_type' => '#microsoft.graph.deviceConfiguration', 'etag' => 'E1'],
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]); ]);
$operationRuns = app(OperationRunService::class); $operationRuns = app(OperationRunService::class);
@ -1366,18 +1373,6 @@
$captureRun->refresh(); $captureRun->refresh();
$inventorySyncRun = createInventorySyncOperationRunWithCoverage(
tenant: $tenant,
statusByType: ['deviceConfiguration' => 'succeeded'],
);
InventoryItem::query()
->where('tenant_id', (int) $tenant->getKey())
->update([
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
'last_seen_at' => now(),
]);
$snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0); $snapshotId = (int) ($profile->fresh()?->active_snapshot_id ?? 0);
expect($snapshotId)->toBeGreaterThan(0); expect($snapshotId)->toBeGreaterThan(0);

View File

@ -43,6 +43,10 @@
it('archives baseline profiles for authorized workspace members', function (): void { it('archives baseline profiles for authorized workspace members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
expect(defined(BaselineProfile::class.'::STATUS_DRAFT'))->toBeFalse()
->and(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse()
->and(defined(BaselineProfile::class.'::STATUS_ARCHIVED'))->toBeFalse();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
]); ]);

View File

@ -3,6 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Support\Baselines\BaselineProfileStatus;
use Filament\Facades\Filament; use Filament\Facades\Filament;
it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void { it('keeps baseline profiles out of tenant panel registration and tenant navigation URLs', function (): void {
@ -23,6 +25,11 @@
it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void { it('keeps baseline profile urls workspace-owned even when a tenant context exists', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => BaselineProfileStatus::Archived->value,
]);
$this->actingAs($user) $this->actingAs($user)
->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); ->withSession([\App\Support\Workspaces\WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
@ -32,5 +39,8 @@
expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles"); expect($workspaceUrl)->not->toContain("/admin/t/{$tenant->external_id}/baseline-profiles");
$this->get($workspaceUrl)->assertOk(); $this->get($workspaceUrl)->assertOk();
$this->get(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))->assertOk();
$this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound(); $this->get("/admin/t/{$tenant->external_id}/baseline-profiles")->assertNotFound();
expect($profile->fresh()->status)->toBe(BaselineProfileStatus::Archived);
}); });

View File

@ -60,7 +60,7 @@ function classifyLegacySnapshotForTest(BaselineSnapshot $snapshot): array
->and(data_get($classification, 'completion_meta.persisted_items'))->toBe(2); ->and(data_get($classification, 'completion_meta.persisted_items'))->toBe(2);
}); });
it('classifies proven empty legacy captures as complete when the producer run confirms zero subjects', function (): void { it('classifies proven empty legacy captures as incomplete no-data snapshots when the producer run confirms zero subjects', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
@ -86,8 +86,9 @@ function classifyLegacySnapshotForTest(BaselineSnapshot $snapshot): array
$classification = classifyLegacySnapshotForTest($snapshot); $classification = classifyLegacySnapshotForTest($snapshot);
expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Complete->value) expect($classification['lifecycle_state'])->toBe(BaselineSnapshotLifecycleState::Incomplete->value)
->and(data_get($classification, 'completion_meta.was_empty_capture'))->toBeTrue(); ->and(data_get($classification, 'completion_meta.was_empty_capture'))->toBeTrue()
->and(data_get($classification, 'completion_meta.finalization_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
}); });
it('classifies ambiguous legacy snapshots as incomplete with a conservative reason code', function (): void { it('classifies ambiguous legacy snapshots as incomplete with a conservative reason code', function (): void {

View File

@ -42,3 +42,31 @@
->assertSee($explanation?->trustworthinessLabel() ?? '') ->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? ''); ->assertSee($explanation?->nextActionText ?? '');
}); });
it('renders no-data baseline-capture result surfaces with the shared zero-subject explanation', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->incomplete(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS)->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
$truth = app(ArtifactTruthPresenter::class)->forBaselineSnapshot($snapshot->fresh());
$explanation = $truth->operatorExplanation;
$this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk()
->assertSee('Result meaning')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee('Result trust')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee($explanation?->nextActionText ?? '');
});

View File

@ -10,6 +10,7 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineTenantAssignment; use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\Compare\CompareStrategyRegistry; use App\Support\Baselines\Compare\CompareStrategyRegistry;
use App\Support\Baselines\Compare\IntuneCompareStrategy; use App\Support\Baselines\Compare\IntuneCompareStrategy;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
@ -407,3 +408,92 @@
->assertSet('uncoveredTypes', ['deviceCompliancePolicy']) ->assertSet('uncoveredTypes', ['deviceCompliancePolicy'])
->assertSet('fidelity', 'meta'); ->assertSet('fidelity', 'meta');
}); });
it('shows the latest blocked capture explanation when no consumable baseline exists yet', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'current_baseline_changed' => false,
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSet('state', 'no_snapshot')
->assertSet('snapshotId', null)
->assertSet('message', 'The latest inventory sync failed, so this capture could not use a credible upstream basis.');
});
it('keeps compare available against the prior consumable snapshot after a zero-subject capture', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$snapshot = BaselineSnapshot::factory()->complete()->create([
'workspace_id' => (int) $tenant->workspace_id,
'baseline_profile_id' => (int) $profile->getKey(),
]);
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
BaselineTenantAssignment::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'baseline_profile_id' => (int) $profile->getKey(),
]);
OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => OperationRunType::BaselineCapture->value,
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
'completed_at' => now(),
'context' => [
'baseline_profile_id' => (int) $profile->getKey(),
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'current_baseline_changed' => false,
'previous_current_snapshot_exists' => true,
],
],
]);
Livewire::test(BaselineCompareLanding::class)
->assertSet('state', 'idle')
->assertSet('snapshotId', (int) $snapshot->getKey())
->assertActionEnabled('compareNow');
});

View File

@ -93,6 +93,9 @@ function seedCaptureProfileForTenant(
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = seedCaptureProfileForTenant($tenant); $profile = seedCaptureProfileForTenant($tenant);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -124,6 +127,31 @@ function seedCaptureProfileForTenant(
expect($run)->not->toBeNull(); expect($run)->not->toBeNull();
expect($run?->status)->toBe('queued'); expect($run?->status)->toBe('queued');
expect(data_get($run?->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey());
});
it('shows the shared capture block on the start surface when no credible inventory basis exists', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = seedCaptureProfileForTenant($tenant, BaselineCaptureMode::Opportunistic, [
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
->assertActionVisible('capture')
->assertActionHasLabel('capture', 'Capture baseline')
->assertActionEnabled('capture')
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
->assertNotified('Cannot start capture')
->assertStatus(200);
Queue::assertNotPushed(CaptureBaselineSnapshotJob::class);
expect(OperationRun::query()->where('type', 'baseline_capture')->count())->toBe(0);
}); });
it('does not start full-content capture when rollout is disabled', function (): void { it('does not start full-content capture when rollout is disabled', function (): void {

View File

@ -14,6 +14,8 @@
it('filters baseline profiles by status inside the current workspace', function (): void { it('filters baseline profiles by status inside the current workspace', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
expect(defined(BaselineProfile::class.'::STATUS_ACTIVE'))->toBeFalse();
$active = BaselineProfile::factory()->active()->create([ $active = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
]); ]);

View File

@ -7,6 +7,7 @@
use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\EditBaselineProfile;
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile; use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Support\Baselines\BaselineProfileStatus;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -45,7 +46,7 @@
expect($profile->scope_jsonb)->toBe([ expect($profile->scope_jsonb)->toBe([
'policy_types' => ['deviceConfiguration'], 'policy_types' => ['deviceConfiguration'],
'foundation_types' => ['assignmentFilter'], 'foundation_types' => ['assignmentFilter'],
]); ])->and($profile->status)->toBe(BaselineProfileStatus::Draft);
expect($profile->canonicalScopeJsonb())->toBe([ expect($profile->canonicalScopeJsonb())->toBe([
'version' => 2, 'version' => 2,
@ -83,7 +84,7 @@
'name' => 'Legacy baseline profile', 'name' => 'Legacy baseline profile',
'description' => null, 'description' => null,
'version_label' => null, 'version_label' => null,
'status' => 'active', 'status' => BaselineProfileStatus::Active->value,
'capture_mode' => 'opportunistic', 'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([ 'scope_jsonb' => json_encode([
'policy_types' => [], 'policy_types' => [],
@ -178,7 +179,7 @@
'name' => 'Legacy lineage profile', 'name' => 'Legacy lineage profile',
'description' => null, 'description' => null,
'version_label' => null, 'version_label' => null,
'status' => 'active', 'status' => BaselineProfileStatus::Active->value,
'capture_mode' => 'opportunistic', 'capture_mode' => 'opportunistic',
'scope_jsonb' => json_encode([ 'scope_jsonb' => json_encode([
'policy_types' => ['deviceConfiguration'], 'policy_types' => ['deviceConfiguration'],

View File

@ -93,6 +93,44 @@ function visibleLivewireText(Testable $component): string
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details')); ->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
}); });
it('shows the shared blocked-inventory explanation for baseline capture runs without a usable snapshot', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'current_baseline_changed' => false,
],
],
'failure_summary' => [
['reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED, 'message' => 'Capture blocked because the latest inventory sync failed.'],
],
'completed_at' => now(),
]);
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
$explanation = $truth->operatorExplanation;
Filament::setTenant(null, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(TenantlessOperationRunViewer::class, ['run' => $run])
->assertSee('Blocked by prerequisite')
->assertSee($explanation?->evaluationResultLabel() ?? '')
->assertSee($explanation?->trustworthinessLabel() ?? '')
->assertSee('Latest inventory sync failed')
->assertSee($explanation?->nextActionText ?? '');
});
it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void { it('shows operator explanation facts for baseline compare runs with nested compare reason context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -328,6 +366,9 @@ function visibleLivewireText(Testable $component): string
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []], 'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]); ]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user); $result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
@ -344,7 +385,10 @@ function visibleLivewireText(Testable $component): string
->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([]) ->and(data_get($effectiveScope, 'legacy_projection.foundation_types'))->toBe([])
->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration']) ->and(data_get($effectiveScope, 'selected_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration']) ->and(data_get($effectiveScope, 'allowed_type_keys'))->toBe(['deviceConfiguration'])
->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([]); ->and(data_get($effectiveScope, 'unsupported_type_keys'))->toBe([])
->and(data_get($run->context, 'baseline_capture.inventory_sync_run_id'))->toBe((int) $inventorySyncRun->getKey())
->and(data_get($run->context, 'baseline_capture.eligibility.phase'))->toBe('preflight')
->and(data_get($run->context, 'baseline_capture.eligibility.ok'))->toBeTrue();
}); });
it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void { it('normalizes legacy compare assignment overrides into canonical effective scope without rewriting the override row', function (): void {

View File

@ -25,6 +25,8 @@
role: 'owner', role: 'owner',
); );
expect($tenant->fresh()->app_status)->toBe('consent_required');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -33,11 +35,14 @@
->assertSee('Lifecycle summary') ->assertSee('Lifecycle summary')
->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.') ->assertSee('This tenant is still onboarding. It remains visible on management and review surfaces, but it is not selectable as active context until onboarding completes.')
->assertDontSee('App status') ->assertDontSee('App status')
->assertDontSee('Consent required')
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed'); ->assertSee('Failed');
}); });
it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void { it('keeps referenced tenant lifecycle context separate from run status in the tenantless operations viewer', function (): void {
expect(array_key_exists('app_status', Tenant::factory()->onboarding()->raw()))->toBeFalse();
$tenant = Tenant::factory()->onboarding()->create([ $tenant = Tenant::factory()->onboarding()->create([
'name' => 'Viewer Separation Tenant', 'name' => 'Viewer Separation Tenant',
]); ]);

View File

@ -38,6 +38,8 @@
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
]); ]);
expect($tenant->fresh()->app_status)->toBe('ok');
$this->actingAs($user); $this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -61,6 +63,17 @@
->and($visibleColumnNames)->not->toContain('provider_connection_state'); ->and($visibleColumnNames)->not->toContain('provider_connection_state');
}); });
it('keeps legacy app status as opt-in test setup instead of a factory default', function (): void {
expect(array_key_exists('app_status', Tenant::factory()->raw()))->toBeFalse();
$tenant = Tenant::factory()->create([
'name' => 'Explicit Historical App Status Tenant',
'app_status' => 'error',
]);
expect($tenant->fresh()->app_status)->toBe('error');
});
it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void { it('keeps lifecycle and rbac separate while leading the provider summary with consent and verification', function (): void {
$tenant = Tenant::factory()->create([ $tenant = Tenant::factory()->create([
'status' => Tenant::STATUS_ONBOARDING, 'status' => Tenant::STATUS_ONBOARDING,
@ -86,6 +99,8 @@
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
]); ]);
expect($tenant->fresh()->app_status)->toBe('consent_required');
$this->actingAs($user); $this->actingAs($user);
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
@ -97,6 +112,7 @@
->assertSee('RBAC status') ->assertSee('RBAC status')
->assertSee('Failed') ->assertSee('Failed')
->assertDontSee('App status') ->assertDontSee('App status')
->assertDontSee('Consent required')
->assertSee('Truth Cleanup Connection') ->assertSee('Truth Cleanup Connection')
->assertSee('Lifecycle') ->assertSee('Lifecycle')
->assertSee('Disabled') ->assertSee('Disabled')

View File

@ -71,5 +71,5 @@
->assertSee('Recent operations'); ->assertSee('Recent operations');
}); });
expect(count(DB::getQueryLog()))->toBeLessThan(80); expect(count(DB::getQueryLog()))->toBeLessThanOrEqual(86);
}); });

View File

@ -17,7 +17,7 @@
'finding_lifecycle', 'finding_lifecycle',
'tenant_lifecycle', 'tenant_lifecycle',
]) ])
->and(array_keys($rules))->toHaveCount(16) ->and(array_keys($rules))->toHaveCount(17)
->and($bindings)->not->toBeEmpty(); ->and($bindings)->not->toBeEmpty();
foreach ($bindings as $binding) { foreach ($bindings as $binding) {

View File

@ -198,6 +198,197 @@
->assertSee('The provider connection will be used for all Graph API calls.'); ->assertSee('The provider connection will be used for all Graph API calls.');
}); });
it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = 'cdcdcdcd-cdcd-cdcd-cdcd-cdcdcdcdcdcd';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Bootstrap Selected Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $entraTenantId,
'entra_tenant_name' => 'Bootstrap Selected Tenant',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'complete',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$component
->assertDontSee('Bootstrap needs attention')
->assertDontSee('Selected bootstrap actions must complete before activation. Return to Bootstrap to remove the selected actions and skip this optional step, or resolve the required permission and start the blocked action again.');
$summaryMethod = new \ReflectionMethod($component->instance(), 'completionSummaryBootstrapSummary');
$summaryMethod->setAccessible(true);
expect($summaryMethod->invoke($component->instance()))->toBe('Selected - 2 action(s) selected');
});
it('renders blocked bootstrap runs as action required in the review summary', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = 'efefefef-efef-efef-efef-efefefefefef';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Bootstrap Blocked Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$verificationRun = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $entraTenantId,
'entra_tenant_name' => 'Bootstrap Blocked Tenant',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$bootstrapRun = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'reason_translation' => [
'operator_label' => 'Permission required',
],
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'complete',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $verificationRun->getKey(),
'bootstrap_operation_types' => ['inventory_sync', 'compliance.snapshot'],
'bootstrap_operation_runs' => ['inventory_sync' => (int) $bootstrapRun->getKey()],
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$summaryMethod = new \ReflectionMethod($component->instance(), 'completionSummaryBootstrapSummary');
$summaryMethod->setAccessible(true);
expect($summaryMethod->invoke($component->instance()))->toBe('Action required - Permission required');
});
it('initializes entangled wizard state keys to avoid Livewire entangle errors', function (): void { it('initializes entangled wizard state keys to avoid Livewire entangle errors', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -213,10 +404,198 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class) ->test(ManagedTenantOnboardingWizard::class)
->assertSet('data.notes', '') ->assertSet('data.notes', '')
->assertSet('data.bootstrap_operation_types', [])
->assertSet('data.override_blocked', false) ->assertSet('data.override_blocked', false)
->assertSet('data.override_reason', ''); ->assertSet('data.override_reason', '');
}); });
it('persists selected bootstrap actions in the onboarding draft state', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = 'dededede-dede-dede-dede-dededededede';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Persist Bootstrap Tenant',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'bootstrap',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$persistMethod = new \ReflectionMethod($component->instance(), 'persistBootstrapSelection');
$persistMethod->setAccessible(true);
$persistMethod->invoke($component->instance(), ['inventory_sync', 'compliance.snapshot']);
$session->refresh();
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
});
it('filters unsupported bootstrap selections from persisted onboarding drafts', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$tenantGuid = '12121212-1212-1212-1212-121212121212';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $tenantGuid,
'status' => Tenant::STATUS_ONBOARDING,
'name' => 'Acme',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $tenantGuid,
'display_name' => 'Platform onboarding connection',
'is_default' => true,
'is_enabled' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'entra_tenant_id' => $tenantGuid,
'entra_tenant_name' => 'Acme',
],
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
[
'key' => 'consent',
'title' => 'Required application permissions',
'status' => 'pass',
'severity' => 'low',
'blocking' => false,
'reason_code' => 'ok',
'message' => 'Consent is ready.',
'evidence' => [],
'next_steps' => [],
],
]),
],
]);
$session = TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $tenantGuid,
'current_step' => 'complete',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
'bootstrap_operation_types' => [
'inventory_sync',
'compliance.snapshot',
'restore.execute',
'entra_group_sync',
'directory_role_definitions.sync',
],
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$component = Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class, ['onboardingDraft' => (int) $session->getKey()]);
$normalizeMethod = new \ReflectionMethod($component->instance(), 'normalizeBootstrapOperationTypes');
$normalizeMethod->setAccessible(true);
expect($normalizeMethod->invoke($component->instance(), [
'inventory_sync',
'compliance.snapshot',
'restore.execute',
'entra_group_sync',
'directory_role_definitions.sync',
]))->toBe(['inventory_sync', 'compliance.snapshot']);
$optionsMethod = new \ReflectionMethod($component->instance(), 'bootstrapOperationOptions');
$optionsMethod->setAccessible(true);
expect(array_keys($optionsMethod->invoke($component->instance())))->toBe(['inventory_sync', 'compliance.snapshot']);
});
it('returns resumable drafts with missing provider connections to the provider connection step', function (): void { it('returns resumable drafts with missing provider connections to the provider connection step', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -1045,7 +1424,10 @@
]), ]),
]); ]);
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']); $component->call('startBootstrap', [
'inventory_sync' => true,
'compliance.snapshot' => true,
]);
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1); Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class); Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class);

View File

@ -13,6 +13,7 @@
use App\Support\Audit\AuditActorType; use App\Support\Audit\AuditActorType;
use App\Support\Audit\AuditOutcome; use App\Support\Audit\AuditOutcome;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
it('derives summary-first audit semantics for baseline capture workflow events', function (): void { it('derives summary-first audit semantics for baseline capture workflow events', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -79,3 +80,58 @@
->and($completed?->targetDisplayLabel())->not->toBeNull() ->and($completed?->targetDisplayLabel())->not->toBeNull()
->and((int) $completed?->operation_run_id)->toBe((int) $run->getKey()); ->and((int) $completed?->operation_run_id)->toBe((int) $run->getKey());
}); });
it('records no-data baseline capture audit metadata without claiming baseline truth changed', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
'capture_mode' => BaselineCaptureMode::Opportunistic->value,
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
]);
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, [
'deviceConfiguration' => 'succeeded',
]);
$operationRunService = app(OperationRunService::class);
$run = $operationRunService->ensureRunWithIdentity(
tenant: $tenant,
type: 'baseline_capture',
identityInputs: ['baseline_profile_id' => (int) $profile->getKey()],
context: [
'baseline_profile_id' => (int) $profile->getKey(),
'source_tenant_id' => (int) $tenant->getKey(),
'effective_scope' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
'baseline_capture' => [
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
'eligibility' => [
'phase' => 'preflight',
'ok' => true,
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
'covered_types' => ['deviceConfiguration'],
'uncovered_types' => [],
],
],
],
initiator: $user,
);
(new CaptureBaselineSnapshotJob($run))->handle(
app(BaselineSnapshotIdentity::class),
app(InventoryMetaContract::class),
app(AuditLogger::class),
$operationRunService,
);
$completed = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'baseline.capture.completed')
->latest('id')
->first();
expect($completed)->not->toBeNull();
expect(data_get($completed?->metadata, 'reason_code'))->toBe(BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS);
expect(data_get($completed?->metadata, 'current_baseline_changed'))->toBeFalse();
expect(data_get($completed?->metadata, 'snapshot_id'))->not->toBeNull();
});

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Operations\TenantlessOperationRunViewer; use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -175,3 +176,77 @@ function governanceRunViewer(TestCase $testCase, $user, Tenant $tenant, Operatio
expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes')) expect(mb_strpos($pageText, 'Missing sections'))->toBeLessThan(mb_strpos($pageText, 'Secondary causes'))
->and($pageText)->toContain('stale evidence'); ->and($pageText)->toContain('stale evidence');
}); });
it('shows failed-latest-inventory baseline capture summaries before diagnostics', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'blocked',
'context' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'subjects_total' => 0,
'current_baseline_changed' => false,
],
],
'failure_summary' => [[
'reason_code' => BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
'message' => 'Capture blocked because the latest inventory sync failed.',
]],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('The baseline capture was blocked because the latest inventory sync failed.')
->assertSee('Latest inventory sync failed')
->assertSee('Artifact impact')
->assertSee('Dominant cause');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'The baseline capture was blocked because the latest inventory sync failed.'))
->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});
it('shows zero-subject baseline capture summaries before diagnostics', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'baseline_capture',
'status' => 'completed',
'outcome' => 'partially_succeeded',
'context' => [
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'baseline_capture' => [
'reason_code' => BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
'subjects_total' => 0,
'current_baseline_changed' => false,
],
],
'summary_counts' => [
'total' => 0,
'processed' => 0,
'failed' => 0,
],
'completed_at' => now(),
]);
$component = governanceRunViewer($this, $user, $tenant, $run)
->assertSee('The baseline capture finished without a usable baseline because no governed subjects were in scope.')
->assertSee('No subjects were in scope')
->assertSee('Primary next step');
$pageText = governanceVisibleText($component);
expect(mb_strpos($pageText, 'The baseline capture finished without a usable baseline because no governed subjects were in scope.'))
->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
});

View File

@ -5,6 +5,7 @@
use App\Notifications\OperationRunCompleted; use App\Notifications\OperationRunCompleted;
use App\Notifications\OperationRunQueued; use App\Notifications\OperationRunQueued;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
@ -232,6 +233,75 @@ function spec230ExpectedNotificationIcon(string $status): string
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful(); $this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
}); });
it('includes baseline truth status in blocked baseline-capture terminal notifications', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'baseline_capture',
'status' => 'queued',
'outcome' => 'pending',
'context' => [
'baseline_capture' => [
'current_baseline_changed' => false,
'previous_current_snapshot_exists' => true,
],
],
]);
app(OperationRunService::class)->finalizeBlockedRun(
run: $run,
reasonCode: BaselineReasonCodes::CAPTURE_INVENTORY_FAILED,
message: 'Capture blocked because the latest inventory sync failed.',
);
$notification = $user->notifications()->latest('id')->first();
expect($notification)->not->toBeNull()
->and(data_get($notification?->data, 'baseline_truth_changed'))->toBeFalse()
->and(data_get($notification?->data, 'reason_translation.operator_label'))->toBe('Latest inventory sync failed')
->and(data_get($notification?->data, 'diagnostic_reason_code'))->toBe(BaselineReasonCodes::CAPTURE_INVENTORY_FAILED)
->and(array_values(data_get($notification?->data, 'supporting_lines', [])))->toContain('Current baseline truth was unchanged.');
});
it('does not emit terminal notifications for initiator-null baseline-capture runs', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => null,
'initiator_name' => 'System',
'type' => 'baseline_capture',
'status' => 'queued',
'outcome' => 'pending',
'context' => [
'baseline_capture' => [
'current_baseline_changed' => false,
],
],
]);
app(OperationRunService::class)->finalizeBlockedRun(
run: $run,
reasonCode: BaselineReasonCodes::CAPTURE_ZERO_SUBJECTS,
message: 'Capture completed without governed subjects in scope.',
);
expect($user->notifications()->count())->toBe(0);
});
it('uses the system operation route for completed notifications delivered to platform users', function (): void { it('uses the system operation route for completed notifications delivered to platform users', function (): void {
$platformUser = PlatformUser::factory()->create([ $platformUser = PlatformUser::factory()->create([
'capabilities' => [ 'capabilities' => [

View File

@ -11,7 +11,12 @@
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\OperationRunService;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\Operations\ExecutionAuthorityMode;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\QueuedExecutionContext;
use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\Verification\VerificationReportSchema; use App\Support\Verification\VerificationReportSchema;
use App\Support\Verification\VerificationReportWriter; use App\Support\Verification\VerificationReportWriter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
@ -340,6 +345,111 @@
->not->toContain('data-shared-zone="diagnostics"'); ->not->toContain('data-shared-zone="diagnostics"');
}); });
it('renders a queued legitimacy blocked verification report in the wizard instead of the empty state', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '20202020-2020-2020-2020-202020202020';
$tenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => $entraTenantId,
'status' => Tenant::STATUS_ONBOARDING,
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId,
'display_name' => 'Blocked queued verification connection',
'is_default' => true,
]);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $user->getKey(),
'initiator_name' => $user->name,
'type' => 'provider.connection.check',
'status' => 'queued',
'outcome' => 'pending',
'context' => [
'provider_connection_id' => (int) $connection->getKey(),
'target_scope' => [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'entra_tenant_id' => $entraTenantId,
],
],
]);
$context = new QueuedExecutionContext(
run: $run,
operationType: 'provider.connection.check',
workspaceId: (int) $workspace->getKey(),
tenant: $tenant,
initiator: $user,
authorityMode: ExecutionAuthorityMode::ActorBound,
requiredCapability: 'providers.view',
providerConnectionId: (int) $connection->getKey(),
targetScope: [
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'entra_tenant_id' => $entraTenantId,
],
);
$decision = QueuedExecutionLegitimacyDecision::deny(
context: $context,
checks: [
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'not_applicable',
'tenant_operability' => 'failed',
'execution_prerequisites' => 'not_applicable',
],
reasonCode: ExecutionDenialReasonCode::TenantNotOperable,
);
app(OperationRunService::class)->finalizeExecutionLegitimacyBlockedRun($run, $decision);
TenantOnboardingSession::query()->create([
'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(),
'entra_tenant_id' => $entraTenantId,
'current_step' => 'verify',
'state' => [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $run->getKey(),
],
'started_by_user_id' => (int) $user->getKey(),
'updated_by_user_id' => (int) $user->getKey(),
]);
$this->actingAs($user)
->followingRedirects()
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Status: Blocked')
->assertSee(ExecutionDenialReasonCode::TenantNotOperable->message())
->assertDontSee('Verification report unavailable');
});
it('keeps one onboarding verification path per state while leaving workflow actions on the wizard step', function (): void { it('keeps one onboarding verification path per state while leaving workflow actions on the wizard step', function (): void {
$workspace = Workspace::factory()->create(); $workspace = Workspace::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();

View File

@ -9,6 +9,7 @@
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\QueuedExecutionContext; use App\Support\Operations\QueuedExecutionContext;
use App\Support\Operations\QueuedExecutionLegitimacyDecision; use App\Support\Operations\QueuedExecutionLegitimacyDecision;
use App\Support\Verification\VerificationReportSchema;
it('writes a blocked terminal audit trail with execution denial context', function (): void { it('writes a blocked terminal audit trail with execution denial context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -64,6 +65,8 @@
->latest('id') ->latest('id')
->first(); ->first();
$report = data_get($run->context, 'verification_report');
expect($audit)->not->toBeNull() expect($audit)->not->toBeNull()
->and($audit?->action)->toBe('operation.blocked') ->and($audit?->action)->toBe('operation.blocked')
->and($audit?->status)->toBe('blocked') ->and($audit?->status)->toBe('blocked')
@ -74,5 +77,10 @@
->and(data_get($audit?->metadata, 'denial_class'))->toBe('initiator_invalid') ->and(data_get($audit?->metadata, 'denial_class'))->toBe('initiator_invalid')
->and(data_get($audit?->metadata, 'authority_mode'))->toBe('actor_bound') ->and(data_get($audit?->metadata, 'authority_mode'))->toBe('actor_bound')
->and(data_get($audit?->metadata, 'acting_identity_type'))->toBe('user') ->and(data_get($audit?->metadata, 'acting_identity_type'))->toBe('user')
->and($run->summary_counts)->toBe(['total' => 4]); ->and($run->summary_counts)->toBe(['total' => 4])
->and($report)->toBeArray()
->and(VerificationReportSchema::isValidReport($report))->toBeTrue()
->and(data_get($report, 'checks.0.key'))->toBe('provider.connection.check')
->and(data_get($report, 'summary.overall'))->toBe('blocked')
->and(data_get($report, 'checks.0.message'))->toBe(ExecutionDenialReasonCode::InitiatorNotEntitled->message());
}); });

View File

@ -76,7 +76,7 @@
->assertDontSee('Inventory sync'); ->assertDontSee('Inventory sync');
})->group('ops-ux'); })->group('ops-ux');
it('does not show likely stale runs in the progress overlay and stops polling when only stale runs remain', function () { it('shows likely stale runs in the progress overlay and keeps polling when only stale runs remain', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -94,8 +94,10 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(BulkOperationProgress::class) ->test(BulkOperationProgress::class)
->call('refreshRuns') ->call('refreshRuns')
->assertSet('hasActiveRuns', false) ->assertSet('hasActiveRuns', true)
->assertDontSee('Inventory sync'); ->assertSee('Inventory sync')
->assertSee('Likely stale')
->assertSee('This operation is past its lifecycle window.');
})->group('ops-ux'); })->group('ops-ux');
it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () { it('registers Alpine cleanup for the Ops UX poller to avoid stale listeners across re-renders', function () {

View File

@ -46,7 +46,7 @@
expect($runs->pluck('user_id')->all())->toContain($otherUser->id); expect($runs->pluck('user_id')->all())->toContain($otherUser->id);
})->group('ops-ux'); })->group('ops-ux');
it('suppresses stale backup set update runs from the progress widget', function (string $operationType): void { it('keeps stale backup set update runs visible in the progress widget', function (string $operationType): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
@ -67,8 +67,9 @@
->call('refreshRuns'); ->call('refreshRuns');
expect($component->get('runs'))->toBeInstanceOf(Collection::class) expect($component->get('runs'))->toBeInstanceOf(Collection::class)
->and($component->get('runs'))->toHaveCount(0) ->and($component->get('runs'))->toHaveCount(1)
->and($component->get('hasActiveRuns'))->toBeFalse(); ->and($component->get('runs')->first()->freshnessState()->value)->toBe('likely_stale')
->and($component->get('hasActiveRuns'))->toBeTrue();
})->with([ })->with([
'backup set update' => 'backup_set.update', 'backup set update' => 'backup_set.update',
])->group('ops-ux'); ])->group('ops-ux');

View File

@ -10,10 +10,22 @@
$this->actingAs($user); $this->actingAs($user);
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
OperationRun::factory()->count(7)->create([ OperationRun::factory()->count(4)->create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'queued', 'status' => 'queued',
'outcome' => 'pending', 'outcome' => 'pending',
'created_at' => now(),
]);
OperationRun::factory()->count(3)->create([
'tenant_id' => $tenant->id,
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => 'queued',
'outcome' => 'pending',
'created_at' => now()->subHour(),
]); ]);
$component = Livewire::actingAs($user) $component = Livewire::actingAs($user)
@ -22,4 +34,6 @@
expect($component->get('runs'))->toHaveCount(6); expect($component->get('runs'))->toHaveCount(6);
expect($component->get('overflowCount'))->toBe(2); expect($component->get('overflowCount'))->toBe(2);
expect($component->get('runs')->map(fn (OperationRun $run): string => $run->freshnessState()->value)->unique()->values()->all())
->toEqualCanonicalizing(['fresh_active', 'likely_stale']);
})->group('ops-ux'); })->group('ops-ux');

View File

@ -48,3 +48,14 @@
expect(BadgeCatalog::mapper(BadgeDomain::BooleanEnabled))->not->toBeNull() expect(BadgeCatalog::mapper(BadgeDomain::BooleanEnabled))->not->toBeNull()
->and($domainValues)->not->toContain('provider_connection.status', 'provider_connection.health'); ->and($domainValues)->not->toContain('provider_connection.status', 'provider_connection.health');
}); });
it('keeps retired tenant app-status out of the central catalog while active tenant domains remain registered', function (): void {
$domainValues = collect(BadgeDomain::cases())
->map(fn (BadgeDomain $domain): string => $domain->value)
->all();
expect($domainValues)->not->toContain('tenant_app_status')
->and(BadgeCatalog::mapper(BadgeDomain::TenantStatus))->not->toBeNull()
->and(BadgeCatalog::mapper(BadgeDomain::TenantRbacStatus))->not->toBeNull()
->and(BadgeCatalog::mapper(BadgeDomain::TenantPermissionStatus))->not->toBeNull();
});

View File

@ -23,18 +23,15 @@
expect($error->color)->toBe('danger'); expect($error->color)->toBe('danger');
}); });
it('maps tenant app status values to legacy diagnostic badge semantics', function (): void { it('does not expose retired app status as active tenant badge semantics', function (): void {
$ok = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'ok'); $domainValues = collect(BadgeDomain::cases())
expect($ok->label)->toBe('OK'); ->map(fn (BadgeDomain $domain): string => $domain->value)
expect($ok->color)->toBe('success'); ->all();
$consentRequired = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'consent_required'); expect($domainValues)->not->toContain('tenant_app_status')
expect($consentRequired->label)->toBe('Consent required'); ->and(BadgeCatalog::mapper(BadgeDomain::TenantStatus))->not->toBeNull()
expect($consentRequired->color)->toBe('warning'); ->and(BadgeCatalog::mapper(BadgeDomain::TenantRbacStatus))->not->toBeNull()
->and(BadgeCatalog::mapper(BadgeDomain::TenantPermissionStatus))->not->toBeNull();
$error = BadgeCatalog::spec(BadgeDomain::TenantAppStatus, 'error');
expect($error->label)->toBe('Error');
expect($error->color)->toBe('danger');
}); });
it('maps tenant RBAC status values to canonical badge semantics', function (): void { it('maps tenant RBAC status values to canonical badge semantics', function (): void {

View File

@ -3,6 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Support\Auth\Capabilities;
use App\Services\Operations\QueuedExecutionLegitimacyGate; use App\Services\Operations\QueuedExecutionLegitimacyGate;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -53,6 +55,95 @@
]); ]);
}); });
it('allows onboarding verification runs for onboarding tenants when they originate from the onboarding wizard', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->forceFill([
'status' => 'onboarding',
])->save();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'provider.connection.check',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
'provider_connection_id' => (int) $connection->getKey(),
'wizard' => [
'flow' => 'managed_tenant_onboarding',
'step' => 'verification',
],
],
]);
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
expect($decision->allowed)->toBeTrue()
->and($decision->reasonCode)->toBeNull()
->and($decision->checks)->toMatchArray([
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'passed',
'tenant_operability' => 'passed',
'execution_prerequisites' => 'passed',
]);
});
it('allows workspace-scoped onboarding bootstrap capabilities during queued reauthorization', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->forceFill([
'status' => 'onboarding',
])->save();
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
]);
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'user_id' => (int) $user->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'context' => [
'execution_authority_mode' => ExecutionAuthorityMode::ActorBound->value,
'provider_connection_id' => (int) $connection->getKey(),
'required_capability' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
'wizard' => [
'flow' => 'managed_tenant_onboarding',
'step' => 'bootstrap',
],
],
]);
$decision = app(QueuedExecutionLegitimacyGate::class)->evaluate($run);
expect($decision->allowed)->toBeTrue()
->and($decision->reasonCode)->toBeNull()
->and($decision->checks)->toMatchArray([
'workspace_scope' => 'passed',
'tenant_scope' => 'passed',
'capability' => 'passed',
'tenant_operability' => 'passed',
'execution_prerequisites' => 'passed',
]);
});
it('denies actor-bound execution when the initiator loses capability', function (): void { it('denies actor-bound execution when the initiator loses capability', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');

View File

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory. > Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs. > This is the "big picture" — not individual specs.
**Last updated**: 2026-04-22 **Last updated**: 2026-04-24
--- ---
@ -13,7 +13,7 @@ ## Release History
|---------|-------|--------| |---------|-------|--------|
| **R1 "Golden Master Governance"** | Baseline drift as production feature, operations polish | **Done** | | **R1 "Golden Master Governance"** | Baseline drift as production feature, operations polish | **Done** |
| **R1 cont.** | Ops canonicalization, action surface contract, ops-ux enforcement | **Done** | | **R1 cont.** | Ops canonicalization, action surface contract, ops-ux enforcement | **Done** |
| **R2 "Tenant Reviews & Evidence"** | Evidence packs, stored reports, permission posture, alerts | **Partial** | | **R2 "Tenant Reviews, Evidence & Control Foundation"** | Evidence packs, stored reports, canonical control catalog, permission posture, alerts | **Partial** |
| **R2 cont.** | Alert escalation + notification routing | **Done** | | **R2 cont.** | Alert escalation + notification routing | **Done** |
--- ---
@ -21,11 +21,11 @@ ## Release History
## Active / Near-term ## Active / Near-term
### Governance & Architecture Hardening ### Governance & Architecture Hardening
Canonical run-view trust semantics, execution-time authorization continuity, tenant-owned query canon, findings workflow enforcement, Livewire trust-boundary reduction. Canonical run-view trust semantics, execution-time authorization continuity, tenant-owned query canon, findings workflow enforcement, Livewire trust-boundary reduction, operation-type canonicalization, provider-boundary hardening, target-scope neutrality, and governed-subject vocabulary enforcement.
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands. Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands, while preventing the Governance-of-Record platform core from drifting into provider-specific or operation-type dual semantics.
**Active specs**: 144 **Active specs**: 144
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate) **Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate). Next foundation candidates: Canonical Operation Type Source of Truth, Provider Boundary Hardening, Provider Identity & Target Scope Neutrality, Platform Vocabulary Boundary Enforcement for Governed Subject Keys.
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane. **Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane.
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates **Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
@ -79,13 +79,25 @@ ### R2.0 Canonical Control Catalog Foundation
- Microsoft subject and workload bindings for tenant-near technical controls - Microsoft subject and workload bindings for tenant-near technical controls
- Small seed catalog for v1 families such as strong authentication, conditional access, privileged access, endpoint baseline or hardening, sharing boundaries, mail protection, audit retention, and delegated admin boundaries - Small seed catalog for v1 families such as strong authentication, conditional access, privileged access, endpoint baseline or hardening, sharing boundaries, mail protection, audit retention, and delegated admin boundaries
- Referenceable from Baseline Profiles, Compare and Drift, Findings, Exceptions, StoredReports, and EvidenceItems - Referenceable from Baseline Profiles, Compare and Drift, Findings, Exceptions, StoredReports, and EvidenceItems
- Foundation for later framework mappings, readiness views, and auditor packs - Foundation for later framework mappings, readiness views, customer review workspaces, and auditor packs
- Explicitly not a late compliance feature: this is the semantic platform layer for tenant reviews, evidence packs, findings, exceptions, stored reports, and future readiness views
### R1.x Foundation Hardening — Governance Platform Anti-Drift
Stabilize the Governance-of-Record platform semantics before additional Microsoft domains, compliance overlays, or multi-cloud execution expand the surface area.
**Goal**: Keep Golden Master Governance from becoming provider-specific feature growth by hardening the platform seams underneath OperationRuns, ProviderConnections, governed subjects, and shared vocabulary.
- Canonical Operation Type Source of Truth for persistence, dispatch, UI labels, audit, alerts, notifications, and reporting
- Provider Boundary Hardening so provider-specific behavior stays inside provider adapters and registries
- Provider Identity & Target Scope Neutrality so Entra-specific identifiers do not become generic platform truth
- Platform Vocabulary Boundary Enforcement for Governed Subject Keys so `policy_type` and similar provider/domain terms do not leak into the platform core
- No AWS/GCP/SaaS connector implementation in this slice; this is anti-drift foundation work only
### R2 Completion — Evidence & Exception Workflows ### R2 Completion — Evidence & Exception Workflows
- Review pack export (Spec 109 — done) - Review pack export (Spec 109 — done)
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft) - Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
- Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft) - Formal evidence/review-pack entity foundation → Spec 153 (evidence snapshots, draft) + Spec 155 (tenant review layer / review packs, draft)
- Workspace-level PII override for review packs → deferred from 109 - Workspace-level PII override for review packs → deferred from 109
- Customer Review Workspace / Read-only View v1 → sharpen customer-facing review consumption: baseline status, latest reviews, findings, accepted risks, evidence/review-pack downloads, customer-safe redaction, and no admin/remediation actions
### Findings Workflow v2 / Execution Layer ### Findings Workflow v2 / Execution Layer
Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation. Turn findings from a reviewable register into an accountable operating flow with clear ownership, personal queues, intake, hygiene, and minimal escalation.
@ -144,10 +156,10 @@ ### PSA / Ticketing Handoff
**Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling. **Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
### Compliance Readiness & Executive Review Packs ### Compliance Readiness & Executive Review Packs
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs. On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, canonical control coverage, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views depend on the Canonical Control Catalog and Evidence-to-Control mapping and remain explicitly without certification claims. Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand. **Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.
**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation. **Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation.
**Depends on**: Canonical Control Catalog Foundation, StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity. **Depends on**: Canonical Control Catalog Foundation, Evidence-to-Control mapping, StoredReports / EvidenceItems foundation, Tenant Review runs, Customer Review Workspace / Read-only View, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
**Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation. **Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation.
**Modeling principle**: Compliance and governance requirements are modeled through a framework-neutral canonical control catalog plus technical interpretations and versioned framework overlays, not as separate technical object worlds per framework. Readiness views, evidence packs, baseline libraries, and auditor outputs are generated from that shared domain model. **Modeling principle**: Compliance and governance requirements are modeled through a framework-neutral canonical control catalog plus technical interpretations and versioned framework overlays, not as separate technical object worlds per framework. Readiness views, evidence packs, baseline libraries, and auditor outputs are generated from that shared domain model.
@ -214,7 +226,7 @@ ## Infrastructure & Platform Debt
| Item | Risk | Status | | Item | Risk | Status |
|------|------|--------| |------|------|--------|
| No `.env.example` in repo | Onboarding friction | Open | | No `.env.example` in repo | Onboarding friction | Open |
| No CI pipeline config | No automated quality gate | Open | | CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited | Review needed |
| No PHPStan/Larastan | No static analysis | Open | | No PHPStan/Larastan | No static analysis | Open |
| SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open | | SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open |
| No formal release process | Manual deploys | Open | | No formal release process | Manual deploys | Open |

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Operation Run Active-State Visibility & Stale Escalation
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated after initial draft creation.
- No clarification markers remain.
- Candidate promoted and removed from the open `Qualified` list in `docs/product/spec-candidates.md`.

View File

@ -0,0 +1,317 @@
openapi: 3.1.0
info:
title: Operation Run Active-State Visibility Logical Contract
version: 1.0.0
description: |
Internal logical contract for Spec 233. This does not introduce a new public API.
It documents the operator-visible active-state payload semantics that existing
Filament and Livewire surfaces derive from current OperationRun lifecycle truth.
servers:
- url: https://logical.tenantpilot.internal
description: Logical contract namespace only
tags:
- name: TenantActivity
- name: WorkspaceMonitoring
- name: CanonicalRunDetail
paths:
/admin/t/{tenant}/dashboard:
get:
tags: [TenantActivity]
summary: Logical tenant activity summary contract
description: |
Represents the active-state payload that tenant dashboard activity and attention
surfaces must expose. Actual implementation is HTML/Filament, not JSON.
operationId: getTenantDashboardOperationActivitySummary
x-logical-contract: true
parameters:
- $ref: '#/components/parameters/TenantId'
responses:
'200':
description: Active-state summary for tenant-visible runs
content:
application/json:
schema:
$ref: '#/components/schemas/TenantActivityOperationsSummary'
'404':
description: Tenant not visible to the current operator
/admin/t/{tenant}/operations/progress:
get:
tags: [TenantActivity]
summary: Logical tenant active-progress overlay contract
description: |
Represents the visible-active payload used by the tenant-local progress overlay.
Stale-active runs remain active for visibility and polling purposes, and
their compact elevation is rendered through the shared badge and Ops UX presenter path.
operationId: getTenantOperationProgressOverlay
x-logical-contract: true
parameters:
- $ref: '#/components/parameters/TenantId'
responses:
'200':
description: Active progress payload for tenant-scoped runs
content:
application/json:
schema:
$ref: '#/components/schemas/TenantOperationProgressOverlay'
'403':
description: Operator is a member but lacks capability to view operation runs
'404':
description: Tenant not visible to the current operator
/admin/operations:
get:
tags: [WorkspaceMonitoring]
summary: Logical canonical operations list contract
description: |
Represents the row-level semantics required by the canonical operations list.
Existing tenant prefilter continuity may be preserved, but active-state meaning
must match the unfiltered list.
operationId: listWorkspaceOperationsWithActiveStateMeaning
x-logical-contract: true
parameters:
- $ref: '#/components/parameters/TenantFilter'
- $ref: '#/components/parameters/ActiveTab'
- $ref: '#/components/parameters/ProblemClass'
responses:
'200':
description: Workspace operations list row contract
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunCollectionContract'
'404':
description: Workspace scope not visible to the current operator
/admin/operations/{run}:
get:
tags: [CanonicalRunDetail]
summary: Logical canonical run-detail contract
description: |
Represents the top-level summary semantics for a canonical operation-run detail page.
The page remains diagnostic-first while preserving the same active-state meaning seen
on compact tenant and workspace surfaces.
operationId: getCanonicalOperationRunDetailActiveStateSummary
x-logical-contract: true
parameters:
- $ref: '#/components/parameters/RunId'
responses:
'200':
description: Canonical operation-run detail summary contract
content:
application/json:
schema:
$ref: '#/components/schemas/OperationRunDetailContract'
'403':
description: Operator is a member of scope but lacks required capability
'404':
description: Run not visible to the current operator or tenant scope
components:
parameters:
TenantId:
name: tenant
in: path
required: true
schema:
type: integer
minimum: 1
RunId:
name: run
in: path
required: true
schema:
type: integer
minimum: 1
TenantFilter:
name: tenant
in: query
required: false
schema:
type: integer
minimum: 1
description: Optional tenant prefilter preserved from tenant-context navigation
ActiveTab:
name: activeTab
in: query
required: false
schema:
type: string
description: Existing canonical monitoring tab selection
ProblemClass:
name: problemClass
in: query
required: false
schema:
type: string
enum:
- none
- active_stale_attention
- terminal_follow_up
schemas:
OperationRunActiveStateProjection:
type: object
additionalProperties: false
required:
- freshness_state
- problem_class
- surface_category
- is_currently_active
- is_reconciled
- show_in_active_progress
- keep_active_polling
properties:
freshness_state:
type: string
enum:
- fresh_active
- likely_stale
- reconciled_failed
- terminal_normal
- unknown
problem_class:
type: string
enum:
- none
- active_stale_attention
- terminal_follow_up
surface_category:
type: string
enum:
- healthy_active
- past_expected_lifecycle
- likely_stale
- no_longer_active
- unknown
description: |
Derived operator-facing category. Compact surfaces may use
`past_expected_lifecycle` where canonical detail uses `likely_stale`
over the same stale truth.
compact_label:
type: string
nullable: true
diagnostic_label:
type: string
nullable: true
lifecycle_label:
type: string
nullable: true
guidance:
type: string
nullable: true
stale_lineage_note:
type: string
nullable: true
is_currently_active:
type: boolean
is_reconciled:
type: boolean
show_in_active_progress:
type: boolean
keep_active_polling:
type: boolean
OperationRunSurfaceItem:
type: object
additionalProperties: false
required:
- run_id
- operation_label
- active_state
properties:
run_id:
type: integer
minimum: 1
tenant_id:
type: integer
minimum: 1
nullable: true
tenant_label:
type: string
nullable: true
operation_label:
type: string
status_label:
type: string
nullable: true
outcome_label:
type: string
nullable: true
active_state:
$ref: '#/components/schemas/OperationRunActiveStateProjection'
detail_url:
type: string
nullable: true
TenantActivityOperationsSummary:
type: object
additionalProperties: false
required:
- tenant_id
- items
properties:
tenant_id:
type: integer
minimum: 1
items:
type: array
items:
$ref: '#/components/schemas/OperationRunSurfaceItem'
TenantOperationProgressOverlay:
type: object
additionalProperties: false
required:
- tenant_id
- has_visible_active_runs
- poll_interval
- items
properties:
tenant_id:
type: integer
minimum: 1
has_visible_active_runs:
type: boolean
poll_interval:
type: string
nullable: true
enum:
- 10s
items:
type: array
items:
$ref: '#/components/schemas/OperationRunSurfaceItem'
OperationRunCollectionContract:
type: object
additionalProperties: false
required:
- items
properties:
tenant_filter:
type: integer
minimum: 1
nullable: true
active_tab:
type: string
nullable: true
problem_class:
type: string
nullable: true
items:
type: array
items:
$ref: '#/components/schemas/OperationRunSurfaceItem'
OperationRunDetailContract:
type: object
additionalProperties: false
required:
- run_id
- operation_label
- active_state
properties:
run_id:
type: integer
minimum: 1
operation_label:
type: string
active_state:
$ref: '#/components/schemas/OperationRunActiveStateProjection'
top_summary:
type: string
nullable: true
diagnostics_visible_after_summary:
type: boolean
const: true

View File

@ -0,0 +1,147 @@
# Data Model: Operation Run Active-State Visibility & Stale Escalation
## Overview
This feature introduces no new persisted entity, table, or stored projection. It formalizes one derived active-state presentation contract over existing `OperationRun` lifecycle truth so tenant and workspace monitoring surfaces present the same meaning.
## Source Entity: OperationRun
- **Purpose**: Canonical lifecycle and outcome record for long-running admin-plane work.
- **Existing fields used by this feature**:
- `id`
- `workspace_id`
- `tenant_id`
- `type`
- `status`
- `outcome`
- `created_at`
- `started_at`
- `completed_at`
- `context`
- `failure_summary`
- **Existing relationships used by this feature**:
- `tenant`
- `user` where available for initiator context
- **Existing invariants**:
- Lifecycle status and outcome remain service-owned.
- Reconciliation metadata stays inside `context.reconciliation`.
- No new persisted status or outcome values are introduced for visibility purposes.
## Derived Truth: OperationRunFreshnessState
- **Type**: Existing enum `App\Support\Operations\OperationRunFreshnessState`
- **Values**:
- `fresh_active`
- `likely_stale`
- `reconciled_failed`
- `terminal_normal`
- `unknown`
- **Inputs**:
- `status`
- `created_at`
- `started_at`
- `context.reconciliation`
- existing `OperationLifecyclePolicy`
- **Behavioral rule**:
- This remains the only stale/late truth input for surface rendering.
- No widget, page, or Livewire component may introduce its own threshold logic.
## Derived Truth: OperationRun Problem Class
- **Type**: Existing derived string on `OperationRun`
- **Values**:
- `none`
- `active_stale_attention`
- `terminal_follow_up`
- **Purpose**:
- Separates active stale attention from terminal follow-up while keeping both distinct from calm/no-action runs.
- **Relationship to freshness**:
- `likely_stale` freshness yields `active_stale_attention`.
- `reconciled_failed` freshness yields `terminal_follow_up`.
- Completed blocked/partial/failed runs may also yield `terminal_follow_up` without stale lineage.
## Derived View Model: Active-State Presentation Contract
- **Type**: Derived, request-scoped presentation payload. Prefer reuse of `OperationUxPresenter::decisionZoneTruth()` and existing badge/presenter outputs before adding any new helper.
- **Required fields across covered surfaces**:
- `freshness_state`
- `problem_class`
- `is_currently_active`
- `is_reconciled`
- `compact_label`
- `diagnostic_label`
- `guidance`
- `stale_lineage_note`
- `show_in_active_progress`
- `keep_active_polling`
- **Presentation categories**:
- `healthy_active`
- `past_expected_lifecycle`
- `likely_stale`
- `no_longer_active`
- `unknown` fallback
- **Category mapping rules**:
- `fresh_active` + active run -> `healthy_active`
- `likely_stale` on compact summary surfaces -> `past_expected_lifecycle`
- `likely_stale` on canonical or stronger diagnostic surfaces -> `likely_stale`
- `terminal_normal` or `reconciled_failed` -> `no_longer_active`
- `unknown` -> fallback copy without false stale escalation
- **Important constraint**:
- `past_expected_lifecycle` and `likely_stale` are density variants over the same stale truth, not separate persisted states.
## Derived Surface Policy: Tenant Active Progress Visibility
- **Current consumers**:
- `App\Livewire\BulkOperationProgress`
- `App\Support\OpsUx\ActiveRuns`
- **Former issue**:
- Both used `healthyActive()` and therefore suppressed stale-active runs from the tenant progress overlay and polling decision.
- **Implemented rule**:
- Fresh and stale active runs remain visible as active work.
- Terminal runs disappear on the next refresh cycle.
- Polling continues while any visible active work remains, including stale-active runs.
- Overlay rendering uses the existing status badge and `OperationUxPresenter` guidance path so stale-active elevation stays derived from shared freshness truth.
## Covered Surface Consumers
| Consumer | Current Truth Inputs | Required Change |
|---|---|---|
| `BulkOperationProgress` | Active run query, `healthyActive()`, `ActiveRuns` | Include stale-active work in visibility and polling semantics while keeping terminal runs excluded |
| `RecentOperationsSummary` | Raw recent runs for tenant | Ensure active-state emphasis and copy stay aligned with canonical freshness meaning |
| `Dashboard\RecentOperations` | Badge rendering + `OperationUxPresenter` | Preserve and tighten existing freshness-aware row semantics |
| `Dashboard\NeedsAttention` / `DashboardKpis` | Problem-class counts + links | Keep stale-active counts and linked monitoring semantics aligned |
| `WorkspaceOverviewBuilder` / `WorkspaceRecentOperations` | Badge rendering + `OperationUxPresenter` | Preserve workspace summary consistency and diagnostic separation |
| `OperationRunResource` | Status/outcome badges + lifecycle summaries | Keep canonical list/detail authoritative and consistent with compact surfaces |
| `TenantlessOperationRunViewer` | Canonical detail page around resource truth | Preserve diagnostic-first explanation of stale versus terminal meaning |
## State Transitions Relevant To This Feature
1. `queued` or `running` within lifecycle threshold
- Freshness: `fresh_active`
- Presentation: `healthy_active`
- Visible on active-only compact surfaces: yes
2. `queued` or `running` beyond lifecycle threshold
- Freshness: `likely_stale`
- Presentation: `past_expected_lifecycle` on compact surfaces, `likely_stale` on diagnostic surfaces
- Visible on active-only compact surfaces: yes
3. `completed` without reconciliation
- Freshness: `terminal_normal`
- Presentation: `no_longer_active`
- Visible on active-only compact surfaces: no
4. `completed` with reconciliation metadata
- Freshness: `reconciled_failed`
- Presentation: `no_longer_active` with stale-lineage diagnostics
- Visible on active-only compact surfaces: no
## Validation Rules And Invariants
- No new `OperationRun.status` or `OperationRun.outcome` values may be added.
- No new persisted `operation_runs` summary or mirror table may be added.
- All stale/late meaning must derive from existing freshness truth.
- Tenant-scoped surfaces must only reflect runs already visible to the current tenant-entitled operator.
- Workspace summaries must stay limited to entitled tenant slices.
- Healthy queued/running work must not inherit stale emphasis.
- Terminal runs must stop appearing in active-only surfaces on the next refresh cycle.

View File

@ -0,0 +1,237 @@
# Implementation Plan: Operation Run Active-State Visibility & Stale Escalation
**Branch**: `233-stale-run-visibility` | **Date**: 2026-04-23 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/233-stale-run-visibility/spec.md`
## Summary
Complete one shared active-state presentation contract on top of the already-existing `OperationRun` lifecycle freshness truth by converging tenant dashboard activity signals, tenant-local active-run progress cards, workspace recent-operations summaries, the canonical operations list, and canonical run detail on the same fresh-versus-past-expected-versus-likely-stale semantics without introducing new persisted run state, new status values, or page-local heuristics.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
**Storage**: Existing PostgreSQL `operation_runs` records and current session/query-backed monitoring navigation state; no new persistence
**Testing**: Focused Pest feature tests over tenant dashboard widgets, tenant active-run progress surfaces, workspace overview operations summaries, canonical operations list/detail pages, and stale-reconciliation semantics
**Validation Lanes**: `fast-feedback`, `confidence`
**Target Platform**: Laravel admin web application in Sail containers with workspace routes under `/admin` and tenant routes under `/admin/t/{tenant}`
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
**Performance Goals**: Preserve existing query shape and request-local presenter work; no additional remote calls, no background-process changes, and no new persisted summary projections
**Constraints**: No new `OperationRun.status` or `OperationRun.outcome` values, no retry/cancel/reconcile-now UX, no new notification channel, no page-local stale heuristics, no cross-tenant leakage, and no second presentation framework beyond the existing badge/presenter path
**Scale/Scope**: Existing tenant dashboard and tenant resource widgets, one Livewire active-progress slice, workspace overview builders/widgets, canonical operations list/detail pages, and their focused feature-test families
## Filament v5 Implementation Contract
- **Livewire v4.0+ compliance**: Preserved. The feature extends existing Filament v5 pages/widgets/resources and Livewire components without introducing legacy Livewire v3 patterns.
- **Provider registration location**: Unchanged. Panel providers remain registered in `bootstrap/providers.php`, not `bootstrap/app.php`.
- **Global search coverage**: `OperationRunResource` already keeps global search disabled via `$isGloballySearchable = false`, so this feature adds no new global-search exposure and does not depend on Edit/View global-search rules.
- **Destructive actions**: No destructive actions are introduced. Existing monitoring/detail actions remain read-only, and this feature must not add retry, cancel, or force-fail controls.
- **Asset strategy**: No new Filament assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only if a future implementation adds registered assets.
- **Testing plan**: Prove semantics with focused feature tests for tenant dashboard activity, tenant progress summaries, workspace recent operations, canonical operations list/detail consistency, and stale-versus-fresh boundary cases. No browser or heavy-governance lane is required for this slice.
## UI / Surface Guardrail Plan
- **Guardrail scope**: Changed surfaces across tenant dashboard activity, tenant-local active-run progress, workspace recent operations summaries, canonical monitoring list rows, and canonical run detail summary
- **Native vs custom classification summary**: Mixed shared-family change using native Filament widgets/resources/pages plus one existing Livewire progress component
- **Shared-family relevance**: Status messaging, dashboard signals/cards, monitoring list presentation, canonical drill-through, and run-detail summary semantics
- **State layers in scope**: `page`, `detail`, and one request-scoped/Livewire compact-progress slice; no new URL-state layer beyond existing monitoring continuity
- **Handling modes by drift class or surface**: Review-mandatory because meaning must stay aligned across multiple existing surfaces and one existing hidden gap (`healthyActive()`-only progress) must be closed without widening scope
- **Repository-signal treatment**: Review-mandatory; the feature changes operator-visible semantics but does not need a hard-stop repo guard
- **Special surface test profiles**: `standard-native-filament`, `monitoring-state-page`, `shared-detail-family`
- **Required tests or manual smoke**: `functional-core`, `state-contract`
- **Exception path and spread control**: None planned. All covered surfaces should consume existing freshness truth and shared presenter/badge paths rather than diverging locally.
- **Active feature PR close-out entry**: `Guardrail`
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `OperationRunFreshnessState`, `OperationLifecycleReconciler`, `OperationRun` problem-class helpers, `OperationUxPresenter`, centralized badge rendering, `BulkOperationProgress`, `RecentOperationsSummary`, `RecentOperations`, `DashboardKpis`, `NeedsAttention`, `WorkspaceOverviewBuilder`, `WorkspaceRecentOperations`, `OperationRunResource`, and `TenantlessOperationRunViewer`
- **Shared abstractions reused**: `OperationRun::freshnessState()`, `OperationRun::problemClass()`, `OperationRunFreshnessState`, `OperationUxPresenter::decisionZoneTruth()`, `OperationUxPresenter::lifecycleAttentionSummary()`, `OperationUxPresenter::surfaceGuidance()`, `ActiveRuns`, `BadgeCatalog` / `BadgeRenderer`, `OperationRunLinks`, and existing workspace/tenant authorization helpers
- **New abstraction introduced? why?**: One bounded derived active-state presentation contract is intentionally made explicit through the existing presenter and active-run helpers so compact and canonical surfaces can stay aligned without introducing a standalone semantic framework.
- **Why the existing abstraction was sufficient or insufficient**: Existing lifecycle truth is sufficient and authoritative. Existing compact-surface adoption is insufficient because some slices already honor freshness (`OperationRunResource`, `RecentOperations`, `WorkspaceOverviewBuilder`) while others still filter to `healthyActive()` or under-communicate stale active work.
- **Bounded deviation / spread control**: Same meaning, different density only. Surface-specific copy may vary by density, but all covered surfaces must consume the same freshness/problem-class truth and must not invent local stale logic.
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design: still passed with one bounded derived presentation contract and no new persisted truth.*
| Gate | Status | Plan Notes |
|------|--------|------------|
| Inventory-first / read-write separation | PASS | The feature is read-only presentation hardening over existing `operation_runs`; no restore, preview, or write-path change is introduced. |
| RBAC, workspace isolation, tenant isolation | PASS | Existing tenant-scoped widgets and canonical workspace monitoring routes remain on current entitlement checks; no new visibility surface is added. |
| Run observability / Ops-UX lifecycle | PASS | `OperationRunService`, lifecycle reconciliation, queued/running/terminal notifications, and run ownership remain unchanged; the plan only changes interpretation and visibility of existing run truth. |
| Shared pattern first | PASS | The plan explicitly reuses existing freshness, problem-class, presenter, and badge paths rather than adding a second semantic layer or local mapping family. |
| Proportionality / no premature abstraction | PASS | The narrowest credible change is one derived presentation contract over current truth plus convergence of existing surfaces. No new persistence, registry, or workflow framework is planned. |
| Badge semantics / Filament-native discipline | PASS | Status-like emphasis stays on centralized badge rendering and existing Filament widgets/resources/pages; no ad-hoc surface-local color system is introduced. |
| Decision-first / operator surfaces | PASS | The operations list remains the primary triage surface, tenant widgets stay secondary context, and canonical run detail stays diagnostic-first. |
| Test governance | PASS | Proof stays in focused feature lanes and existing surface families, with no browser-lane promotion and no heavy shared test infrastructure growth. |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for tenant dashboard activity, tenant progress surfaces, workspace recent operations summaries, canonical operations list/detail consistency, and stale boundary semantics
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The business truth is cross-surface semantic consistency over existing `OperationRun` freshness state. That is fully provable with focused feature tests against the touched widgets/pages and existing reconciliation truth; browser coverage would add cost without validating additional domain behavior.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetFiltersTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need representative fresh queued/running runs, likely stale runs, reconciled terminal runs, tenant membership context, workspace overview payloads, and hidden-tenant/non-member isolation boundaries, but existing factories and operation-run helpers already cover most of that setup.
- **Expensive defaults or shared helper growth introduced?**: No. Existing `OperationRun` factories and workspace/tenant test helpers should stay opt-in and sufficient.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: Standard native-Filament relief plus the existing `monitoring-state-page` proving profile for canonical monitoring pages and summaries
- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused feature command above. Reviewers should verify that stale active work is visible on every covered compact surface, healthy active work is not falsely escalated, and drill-through into canonical detail preserves the same active-state meaning.
- **Budget / baseline / trend follow-up**: none
- **Review-stop questions**: Did any surface invent its own stale threshold? Did `healthyActive()` filtering remain in a surface that should show stale-active attention? Did any test rely on status strings alone instead of freshness truth? Did any change accidentally widen visibility beyond entitled tenant/workspace scope?
- **Escalation path**: `document-in-feature`
- **Active feature PR close-out entry**: `Guardrail`
- **Why no dedicated follow-up spec is needed**: This is bounded current-release convergence of an existing truth family. A separate follow-up spec is only needed if later work tries to add intervention actions or a broader operations workbench.
## Project Structure
### Documentation (this feature)
```text
specs/233-stale-run-visibility/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── operation-run-active-state-visibility.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── Operations/TenantlessOperationRunViewer.php
│ │ ├── Resources/
│ │ │ └── OperationRunResource.php
│ │ └── Widgets/
│ │ ├── Dashboard/
│ │ │ ├── DashboardKpis.php
│ │ │ ├── NeedsAttention.php
│ │ │ └── RecentOperations.php
│ │ ├── Tenant/
│ │ │ └── RecentOperationsSummary.php
│ │ └── Workspace/
│ │ └── WorkspaceRecentOperations.php
│ ├── Livewire/
│ │ └── BulkOperationProgress.php
│ ├── Models/
│ │ └── OperationRun.php
│ ├── Services/Operations/
│ │ └── OperationLifecycleReconciler.php
│ └── Support/
│ ├── Badges/Domains/OperationRunStatusBadge.php
│ ├── OperationRunLinks.php
│ ├── OpsUx/
│ │ ├── ActiveRuns.php
│ │ └── OperationUxPresenter.php
│ ├── Operations/OperationRunFreshnessState.php
│ └── Workspaces/WorkspaceOverviewBuilder.php
├── resources/views/
│ ├── filament/widgets/
│ │ ├── tenant/recent-operations-summary.blade.php
│ │ └── workspace/workspace-recent-operations.blade.php
│ └── livewire/
│ ├── bulk-operation-progress.blade.php
│ └── bulk-operation-progress-wrapper.blade.php
└── tests/
└── Feature/
├── Filament/
│ ├── DashboardKpisWidgetTest.php
│ ├── NeedsAttentionWidgetTest.php
│ ├── OperationRunEnterpriseDetailPageTest.php
│ ├── RecentOperationsSummaryWidgetTest.php
│ └── WorkspaceOverviewOperationsTest.php
├── Monitoring/
│ ├── MonitoringOperationsTest.php
│ ├── OperationLifecycleFreshnessPresentationTest.php
│ └── OperationsDashboardDrillthroughTest.php
└── Operations/
└── TenantlessOperationRunViewerTest.php
├── OpsUx/
│ ├── BulkOperationProgressDbOnlyTest.php
│ ├── NonLeakageWorkspaceOperationsTest.php
│ ├── ProgressWidgetFiltersTest.php
│ └── ProgressWidgetOverflowTest.php
└── RunAuthorizationTenantIsolationTest.php
```
**Structure Decision**: Single Laravel application inside `apps/platform`. Runtime work stays in existing monitoring widgets/resources/pages and one existing Livewire progress slice; planning artifacts stay under `specs/233-stale-run-visibility`.
## Complexity Tracking
No constitutional violation is planned. One bounded derived presentation contract is intentionally tracked because the spec introduces a small new semantic family over existing truth.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| BLOAT-001 derived category family | Compact surfaces currently disagree in practice about whether stale active work is still ordinary progress. One small derived contract keeps surface meaning aligned without changing persisted run state. | Leaving each widget to infer meaning from raw `status` or local heuristics would preserve drift and make future regressions likely. |
## Proportionality Review
- **Current operator problem**: Compact operator surfaces can still hide or understate that active work is already past its expected lifecycle, so operators get false reassurance until they drill into monitoring detail.
- **Existing structure is insufficient because**: Existing freshness truth and presenter helpers already exist, but they are not applied consistently across tenant progress and summary surfaces, and one slice (`BulkOperationProgress`) still intentionally filters stale active work out.
- **Narrowest correct implementation**: Reuse current freshness/problem-class truth, introduce at most one small derived active-state presentation contract, and retrofit only the existing tenant/workspace/canonical monitoring surfaces that already summarize active work.
- **Ownership cost created**: Small ongoing maintenance of one derived category family, shared copy alignment, and focused regression tests across covered surfaces.
- **Alternative intentionally rejected**: Adding new persisted `OperationRun` statuses or separate page-local stale heuristics. Both would widen lifecycle scope or create contradictory truth.
- **Release truth**: Current-release truth and operator-trust hardening.
## Phase 0 Research Summary
- Existing lifecycle and freshness truth already live in `OperationRunFreshnessState`, `OperationRun::problemClass()`, and `OperationLifecycleReconciler`; the feature should consume them rather than create new thresholds.
- Canonical monitoring surfaces already partially honor stale-active semantics: `OperationRunResource`, `Dashboard\RecentOperations`, and `WorkspaceOverviewBuilder` all feed badge/presenter state with `freshness_state` or lifecycle summaries.
- The clearest gap was tenant-local active-progress visibility: `BulkOperationProgress` scoped to `healthyActive()`, which hid stale active work from a high-frequency tenant surface and created exactly the cross-surface contradiction the spec describes.
- `OperationUxPresenter::surfaceGuidance()` already differentiates likely stale, reconciled failed, and ordinary queued/running work, so Phase 1 should extend adoption before inventing new presentation machinery.
- Existing focused tests already cover parts of the semantics (`OperationLifecycleFreshnessPresentationTest`, `MonitoringOperationsTest`, `RecentOperationsSummaryWidgetTest`, `WorkspaceOverviewOperationsTest`, `OperationRunEnterpriseDetailPageTest`, `TenantlessOperationRunViewerTest`), so implementation should prefer extending those families over introducing new broad suites.
## Phase 1 Design Summary
- `data-model.md` defines the derived active-state presentation model over existing `OperationRun`, freshness state, problem class, and covered surface consumers.
- `contracts/operation-run-active-state-visibility.logical.openapi.yaml` documents the internal logical contract for how covered surfaces derive and display active-state meaning from existing run truth.
- `quickstart.md` gives the narrow validation path for fresh-versus-stale fixtures, compact-surface rendering, canonical drill-through, and regression checks.
## Implementation Strategy
1. **Converge on one freshness-to-surface contract**
- Reuse `OperationUxPresenter::decisionZoneTruth()`, `lifecycleAttentionSummary()`, current badge helpers, and `ActiveRuns` as the default convergence path.
- Keep all thresholds and lifecycle windows owned by existing freshness truth.
2. **Fix the tenant-local active-progress blind spot**
- Update `BulkOperationProgress` so stale active runs are not silently excluded from tenant-local progress visibility.
- Preserve calm presentation for healthy active work while allowing stale/late work to escalate visibly.
3. **Align tenant dashboard and tenant-summary surfaces**
- Reconcile `DashboardKpis`, `NeedsAttention`, `RecentOperationsSummary`, and any shared tenant activity slices so they expose the same active-state meaning and drill-through expectations.
- Ensure mixed tenant activity does not over-generalize one stale run into “all activity is stale.”
4. **Keep workspace and canonical monitoring surfaces authoritative**
- Reuse existing freshness-aware row/detail rendering in `OperationRunResource`, `TenantlessOperationRunViewer`, and `WorkspaceOverviewBuilder`, tightening copy and top-level summary semantics only where necessary.
- Preserve canonical list/detail roles and existing filter continuity from tenant context.
5. **Regression-protect fresh versus stale boundaries**
- Extend the existing monitoring and Filament feature tests to prove fresh active, likely stale, reconciled terminal, and terminal-transition cases across covered surfaces.
- Explicitly assert that healthy queued/running runs do not inherit stale emphasis and that terminal runs disappear from active-only compact surfaces after refresh.
## Risks and Mitigations
- **Surface drift survives in one slice**: A compact surface may continue to rely on `status` only. Mitigation: inventory and update every covered surface in this plan, with tests tied to each family.
- **Over-escalation of healthy active work**: Copy or badge reuse could make all queued/running work feel unhealthy. Mitigation: keep the proving fixtures split between fresh and stale runs and assert negative cases explicitly.
- **Tenant progress regression**: Broadening `BulkOperationProgress` could accidentally turn a calm progress bar into a noisy problem board. Mitigation: keep one bounded active-state distinction and preserve existing density expectations.
- **New semantic layer grows too far**: It would be easy to invent a broader taxonomy. Mitigation: constrain the plan to one derived presentation contract backed entirely by existing freshness/problem-class truth.
## Implementation Close-Out
- **Finalized affected surfaces**: Tenant active progress overlay and polling now include all active `OperationRun` records, including stale-active runs. Tenant summary, dashboard KPI/attention, workspace overview, canonical operations list, and canonical detail surfaces already consume shared freshness, badge, and presenter paths and were validated without widening the runtime change.
- **Density-specific copy retained**: Compact surfaces use shared badge copy such as `Likely stale` plus `OperationUxPresenter::surfaceGuidance()` text about being past the lifecycle window. Canonical detail keeps the stronger `Likely stale operation` diagnostic banner.
- **Test-governance disposition**: `document-in-feature`. Coverage stayed inside existing feature-test families and the focused `fast-feedback` / `confidence` lanes; no browser lane, heavy-governance family, shared fixture widening, or new test infrastructure was introduced.
## Post-Design Re-check
Phase 0 and Phase 1 outputs keep the feature within existing `OperationRun` lifecycle truth, existing Filament/Livewire surfaces, and focused feature-test families. The plan remains constitution-compliant, Livewire v4 / Filament v5 compliant, and ready for `/speckit.tasks`.

View File

@ -0,0 +1,79 @@
# Quickstart: Operation Run Active-State Visibility & Stale Escalation
## Preconditions
1. Start the application stack:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail up -d`
2. Work from branch `233-stale-run-visibility`.
3. Keep the scope bounded to existing admin-plane monitoring and progress surfaces.
## Primary Files To Review First
- `apps/platform/app/Models/OperationRun.php`
- `apps/platform/app/Support/Operations/OperationRunFreshnessState.php`
- `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
- `apps/platform/app/Support/OpsUx/ActiveRuns.php`
- `apps/platform/app/Livewire/BulkOperationProgress.php`
- `apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php`
- `apps/platform/app/Filament/Widgets/Dashboard/RecentOperations.php`
- `apps/platform/app/Filament/Resources/OperationRunResource.php`
- `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
## Recommended Implementation Order
1. **Lock the truth source**
- Confirm no surface-local stale thresholds exist outside `OperationRunFreshnessState` and `OperationRun::problemClass()`.
- Reuse `OperationUxPresenter::decisionZoneTruth()`, `lifecycleAttentionSummary()`, and `surfaceGuidance()` wherever possible.
2. **Fix tenant progress visibility first**
- Update `ActiveRuns` and `BulkOperationProgress` so stale-active runs still count as active work for visibility and polling.
- Keep terminal runs disappearing on the next refresh cycle.
- Render stale-active elevation through the shared operation status badge and `OperationUxPresenter` guidance path.
3. **Converge tenant and workspace summary surfaces**
- Align `RecentOperationsSummary`, `Dashboard\RecentOperations`, `Dashboard\NeedsAttention`, `DashboardKpis`, and `WorkspaceOverviewBuilder` on the same compact/detailed stale-active semantics.
- Do not create a new dashboard surface family.
4. **Tighten canonical monitoring consistency last**
- Preserve `OperationRunResource` and `TenantlessOperationRunViewer` as the authoritative diagnostic surfaces.
- Adjust top-level explanation or row emphasis only where it improves consistency with the compact surfaces.
5. **Update focused tests in the same slice**
- Flip stale-hidden assertions in the tenant progress tests.
- Extend monitoring, widget, and visibility-safety tests to prove fresh versus stale boundaries, terminal transitions, and hidden-tenant/non-member isolation.
## Focused Test Matrix
| Scenario | Expected Result | Likely Test Family |
|---|---|---|
| Fresh queued/running run on tenant surface | Visible as healthy active work, no stale escalation | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php` |
| Stale queued/running run on tenant surface | Still visible as active work, but clearly elevated | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/OpsUx/ProgressWidgetFiltersTest.php` |
| Stale run on workspace summaries | Scanable as attention-worthy, not collapsed into calm recency | `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php` |
| Stale run on canonical list/detail | Same active-state meaning preserved after drill-through | `tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` |
| Terminal transition after refresh | Removed from active-only overlays and no longer presented as active | `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php` |
| Hidden or out-of-scope runs during summary rendering | Remain invisible and do not alter visible active-state summaries | `tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`, `tests/Feature/RunAuthorizationTenantIsolationTest.php` |
## Minimum Validation Commands
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetFiltersTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Smoke Checklist
1. Seed one fresh running run and one stale queued/running run for the same tenant.
2. Open the tenant dashboard and confirm the stale run is visible and clearly elevated without making the fresh run look unhealthy.
3. Confirm the tenant progress surface keeps polling while only stale-active work remains.
4. Open `/admin/operations` and verify the same run is distinguishable at row level.
5. Drill into `/admin/operations/{run}` and confirm the top summary preserves the same active-state meaning before raw diagnostics.
## Out Of Scope Guardrails
- Do not add retry, cancel, reconcile-now, or worker-control actions.
- Do not add new notifications or queued/running DB notifications.
- Do not add new persisted run summary data or new `OperationRun` status values.
- Do not widen the work into a new operations workbench or cross-workspace fleet view.

View File

@ -0,0 +1,49 @@
# Research: Operation Run Active-State Visibility & Stale Escalation
## Decision 1: Keep lifecycle freshness truth in the existing run model and reconciler
- **Decision**: Use `OperationRunFreshnessState`, `OperationRun::freshnessState()`, `OperationRun::problemClass()`, and `OperationLifecycleReconciler` as the only lifecycle-truth inputs for this feature.
- **Rationale**: The application already computes `fresh_active`, `likely_stale`, `reconciled_failed`, `terminal_normal`, and `unknown` from the run record plus `OperationLifecyclePolicy`. Canonical monitoring surfaces already rely on that truth, so adding a second stale heuristic would immediately recreate the drift this spec is trying to remove.
- **Alternatives considered**:
- Add new `OperationRun.status` values such as `stale` or `late`: rejected because the distinction is presentation and triage-oriented, not a new persisted lifecycle state.
- Add page-local thresholds per widget: rejected because it would create conflicting meaning across tenant, workspace, and canonical monitoring surfaces.
## Decision 2: Reuse the existing Ops UX presenter path before introducing a new helper
- **Decision**: Prefer `OperationUxPresenter::decisionZoneTruth()`, `lifecycleAttentionSummary()`, `surfaceGuidance()`, and centralized badge rendering as the presentation backbone.
- **Rationale**: The code already exposes a derived decision-zone payload and shared stale/reconciled copy. `OperationRunStatusBadge` already renders `Likely stale` when queued/running work carries `freshness_state=likely_stale`, and `OperationUxPresenter` already provides compact and diagnostic explanations off the same truth.
- **Alternatives considered**:
- New dedicated presenter family for active-state visibility: rejected unless the existing presenter path proves insufficient during implementation.
- Widget-local copy branches: rejected because they would increase semantic spread and regression risk.
## Decision 3: Treat stale-active runs as still active for tenant progress visibility
- **Decision**: Change tenant-local active-progress visibility to include freshness-elevated active runs rather than suppressing them via `healthyActive()`.
- **Rationale**: `BulkOperationProgress` and `ActiveRuns::existForTenantId()` previously used `healthyActive()`, which caused stale queued/running work to disappear from the tenant progress overlay and stopped polling when only stale runs remained. That was the clearest concrete contradiction with the canonical monitoring surfaces.
- **Alternatives considered**:
- Keep stale runs hidden in the progress overlay and rely on dashboard/list only: rejected because the spec explicitly covers tenant-local active-run cards and progress summaries.
- Add a separate stale-only overlay: rejected because it would create a second active-work surface family instead of fixing the existing one.
## Decision 4: Preserve current surface roles and drill-through flow
- **Decision**: Keep the current route and surface model: tenant dashboard and tenant progress remain secondary context, `/admin/operations` remains the primary triage list, and `/admin/operations/{run}` remains diagnostic-first.
- **Rationale**: Existing links already converge through `OperationRunLinks`, and current pages/widgets match the constitution's decision-first model. The gap is the honesty of compact active-state messaging, not missing routes.
- **Alternatives considered**:
- New operations hub or new tenant-local detail page: rejected as unnecessary workflow expansion.
- New notification channel for stale active work: rejected because the spec explicitly excludes new notification behavior.
## Decision 5: Extend existing focused tests and invert stale-hidden assumptions where necessary
- **Decision**: Update existing monitoring, Filament, and Ops UX tests rather than creating a new broad suite.
- **Rationale**: The repository already has focused coverage for lifecycle presentation and tenant progress behavior. In particular, `BulkOperationProgressDbOnlyTest` and `ProgressWidgetFiltersTest` currently codify the stale-hidden behavior that this feature must deliberately replace.
- **Alternatives considered**:
- Add a brand-new browser suite: rejected because feature tests already cover the underlying business truth and UI copy.
- Leave old progress-widget tests untouched and add parallel tests: rejected because the old assertions would preserve the wrong contract.
## Decision 6: Keep “past expected lifecycle” and “likely stale” as density-specific labels over the same stale truth
- **Decision**: Model compact “past expected lifecycle” phrasing and stronger “likely stale” diagnostic phrasing as different density outputs over the same `likely_stale` freshness truth rather than as separate persisted states.
- **Rationale**: The spec allows same meaning, different density. The current code already points in that direction: `OperationUxPresenter::surfaceGuidance()` says the run is “past its lifecycle window,” while `OperationRunStatusBadge` can label the same run `Likely stale`.
- **Alternatives considered**:
- Create two separate freshness states for “late” and “likely stale”: rejected because existing lifecycle truth has only one stale boundary and no additional behavioral consequence.
- Collapse all stale-active copy to a single label everywhere: rejected because compact surfaces and canonical detail need different density without changing meaning.

View File

@ -0,0 +1,252 @@
# Feature Specification: Operation Run Active-State Visibility & Stale Escalation
**Feature Branch**: `233-stale-run-visibility`
**Created**: 2026-04-23
**Status**: Draft
**Input**: User description: "Operation Run Active-State Visibility & Stale Escalation"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: `OperationRun` lifecycle truth already exists, but compact operator surfaces still under-communicate when active work is past expectation or likely stuck.
- **Today's failure**: A run can be visibly stale on the canonical monitoring detail yet still read like ordinary `Queued` or `Running` work on tenant cards, dashboard attention surfaces, or list rows. Operators then receive false reassurance until they drill into monitoring.
- **User-visible improvement**: Active runs that are healthy, late, or likely stuck become visibly distinct across tenant and workspace surfaces, so operators can see unhealthy work in one scan without losing the canonical run detail as the diagnostic source of truth.
- **Smallest enterprise-capable version**: Add one bounded active-state presentation contract derived from existing lifecycle, freshness, and reconciliation truth, then apply it consistently to tenant dashboard activity surfaces, tenant-local active-run cards, the workspace operations list, and the canonical run detail summary.
- **Explicit non-goals**: No new `OperationRun` status values, no retry or cancel actions, no queue or worker redesign, no new notification channel, no full operations hub redesign, no cross-workspace fleet monitoring, and no parallel UI-only stale heuristic that bypasses existing lifecycle truth.
- **Permanent complexity imported**: One bounded derived active-state category family over existing run truth, one shared cross-surface presentation contract, focused operator copy updates on existing monitoring surfaces, and regression coverage for fresh versus stale semantics across tenant and workspace entry points.
- **Why now**: This is an active near-term operator-trust hardening item in the roadmap, and it becomes more urgent as more governance, evidence, and review workflows depend on `OperationRun`. Spec 232 now hardens link continuity into canonical monitoring, so the next high-leverage gap is what those linked surfaces actually communicate about unhealthy active work.
- **Why not local**: Fixing one widget or one row badge would still leave contradictory lifecycle meaning across cards, list rows, dashboard attention, and run detail. The problem is cross-surface truth drift, not one local rendering bug.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Cross-cutting interaction-class scope plus one new derived presentation category family. Defense: the feature derives entirely from existing lifecycle and freshness truth, avoids persistence or backend-state expansion, and stays bounded to existing admin-plane surfaces.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, tenant, canonical-view
- **Primary Routes**:
- `/admin/t/{tenant}` for tenant dashboard activity and attention surfaces
- Existing tenant-local active-run cards linked from tenant-scoped admin surfaces that already summarize active work
- `/admin/operations` as the canonical workspace monitoring list
- `/admin/operations/{run}` as the canonical run detail surface
- **Data Ownership**: `operation_runs` remain the only source of lifecycle and freshness truth. Tenant-local cards, dashboard signals, and workspace monitoring rows remain derived read models over existing run records, lifecycle policy, and stale-detection truth. No new persisted visibility flag, summary mirror, or auxiliary active-run table is introduced.
- **RBAC**: Admin-plane workspace membership remains required for `/admin/operations` and run detail visibility. Tenant-scoped surfaces remain constrained by current tenant entitlement. Non-members and out-of-scope tenant requests remain deny-as-not-found. This feature does not introduce new capability strings, new roles, or new mutation permissions.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When operators enter `/admin/operations` from a tenant-scoped surface, the canonical monitoring list may preserve the active tenant as a default prefilter while retaining the same workspace-level route and clearing behavior used by existing canonical monitoring links.
- **Explicit entitlement checks preventing cross-tenant leakage**: Tenant-local cards and dashboard signals may summarize only runs already visible to the current operator within that tenant. The canonical operations list and run detail continue to re-check workspace membership and tenant entitlement before rendering. No stale or past-lifecycle signal may reveal the existence of hidden runs or tenants.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, dashboard signals/cards, monitoring list presentation, canonical run detail summary
- **Systems touched**: tenant dashboard activity surfaces, tenant-local active-run cards, workspace monitoring list rows, canonical monitoring detail, and existing canonical drill-through links into `/admin/operations`
- **Existing pattern(s) to extend**: current `OperationRun` lifecycle policies, freshness/stale detection, canonical monitoring pages, shared badge semantics, and existing tenant-to-monitoring drill-through patterns
- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunService` remains the sole owner of lifecycle transitions; `App\Support\OperationCatalog` remains the canonical operation label source; existing central badge rendering remains the status-like rendering path; existing canonical operations links remain the navigation path into `/admin/operations`
- **Why the existing shared path is sufficient or insufficient**: Existing lifecycle, freshness, and reconciliation truth are sufficient and must remain authoritative. Existing compact-surface presentation is insufficient because it compresses unhealthy active work too aggressively and can contradict the canonical run detail.
- **Allowed deviation and why**: Same meaning, different density is allowed. Tenant cards, dashboard signals, list rows, and run detail may vary in information density, but they may not disagree about whether active work is healthy, late, or likely stuck.
- **Consistency impact**: Active-state language, status emphasis, badge meaning, next-step cues, and drill-through expectations must stay aligned across tenant dashboards, tenant cards, monitoring rows, and canonical detail.
- **Review focus**: Reviewers must verify that no compact surface presents a run as ordinary active work when the canonical monitoring detail presents it as past expectation or likely stuck, and that no new page-local stale heuristic bypasses the shared lifecycle truth.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Tenant dashboard activity and attention surfaces | yes | Native Filament dashboard widgets and shared summary primitives | Same tenant-home signal family as existing attention and activity summaries | summary, attention cues, drill-through links | no | Existing surfaces only; no new dashboard page |
| Tenant-local active-run cards and progress summaries | yes | Native Filament widgets/cards and shared run primitives | Same tenant-scoped activity family as existing recent-run and progress hints | compact active-state presentation, drill-through links | no | Existing cards only; no new card family |
| Workspace operations list / monitoring rows | yes | Native Filament table and shared run presentation primitives | Same canonical monitoring family as existing `/admin/operations` list | row-level active-state emphasis, filter continuity, list scanability | no | Existing registry surface only |
| Canonical operation run detail summary | yes | Native Filament detail surface and shared run summary primitives | Same canonical monitoring detail family | top-level active-state explanation, diagnostics separation | no | Detail remains diagnostic-first, not a new page |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Tenant dashboard activity and attention surfaces | Secondary Context Surface | An operator lands on the tenant dashboard and decides whether current tenant activity needs immediate follow-up | Whether active work is healthy, needs attention, or likely stuck, plus one drill-through into monitoring | Full run history, raw run context, and extended diagnostics on the canonical monitoring surfaces | Secondary because the dashboard signals attention but should not replace the monitoring register | Follows tenant-home to monitoring workflow instead of creating a second workbench | Removes the need to open monitoring just to learn that active work is already stale or late |
| Tenant-local active-run cards and progress summaries | Secondary Context Surface | An operator inspects one tenant-scoped active-work summary and decides whether to open monitoring now | Current run identity, active-state category, and whether the work is merely active or needs attention | Full lifecycle history, stale-cause detail, and related diagnostics on run detail | Secondary because the card summarizes active work inside a broader tenant workflow | Follows tenant-scoped operational follow-up without duplicating monitoring detail | Prevents misleading neutral `Queued` or `Running` summaries from hiding unhealthy work |
| Workspace operations list / monitoring rows | Primary Decision Surface | A workspace operator scans the active operations register and decides which run needs inspection first | Whether active runs are normal, past expectation, or likely stuck, together with run identity and scope context | Full run diagnostics, raw context, and related artifacts on run detail | Primary because this is the canonical list where operators prioritize run follow-up | Follows monitoring and triage workflow instead of forcing row-by-row drill-in | Makes unhealthy active runs scanable without opening every row |
| Canonical operation run detail summary | Tertiary Evidence / Diagnostics Surface | After choosing one run, the operator confirms what kind of active-state problem exists and what it means | Clear top-level explanation of active-state category, lifecycle expectation status, and why diagnostics matter | Raw payloads, stack traces, reconciliation context, and deep technical detail | Tertiary because the operator first decides to inspect the run elsewhere; this page then provides the authoritative diagnosis | Preserves the existing monitoring-detail workflow | Reduces back-and-forth between list rows and diagnostics to understand whether the run is merely active or likely stuck |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard activity and attention surfaces | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Open the tenant-scoped monitoring slice or the highlighted run | Explicit drill-through CTA or linked run summary | forbidden | Dashboard CTAs remain limited to monitoring follow-up | none | `/admin/t/{tenant}` | `/admin/operations/{run}` | Tenant context, activity state, attention weighting | Operations / Operation | Whether tenant-visible active work is healthy, late, or likely stuck | Embedded summary drill-in only |
| Tenant-local active-run cards and progress summaries | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Open the run detail or canonical monitoring list | Explicit linked run summary | forbidden | Card CTA stays secondary to the active-state summary | none | `/admin/t/{tenant}` | `/admin/operations/{run}` | Tenant context, active work scope, active-state emphasis | Active operations / Operation | Whether the highlighted active work is ordinary progress or already needs attention | Embedded compact summary; no new surface family |
| Workspace operations list / monitoring rows | List / Table / Bulk | Read-only Registry / Report Surface | Open the run most likely to need follow-up | Full-row open to canonical run detail | required | Existing filters and list controls stay in table chrome, not row noise | none | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, optional tenant prefilter, active-state emphasis | Operations / Operation | Which active runs are healthy versus problematic without opening every row | none |
| Canonical operation run detail summary | Record / Detail / Edit | Detail-first Operational Surface | Inspect one run's active-state explanation and diagnostics | Canonical run detail page | n/a | Related links and secondary diagnostics stay below the top-level summary | Existing dangerous follow-up actions remain wherever already governed; none are added here | `/admin/operations` | `/admin/operations/{run}` | Workspace scope, tenant context when applicable, active-state explanation | Operation run | Why the run is healthy, late, or likely stuck before raw diagnostics appear | canonical evidence detail |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard activity and attention surfaces | Tenant operator | Decide whether tenant-visible activity already needs monitoring follow-up | Summary drill-in | Is anything actively happening on this tenant that is already late or likely stuck? | Active-state category, tenant-visible run identity, and one drill-through path | Full run diagnostics and raw lifecycle context remain in monitoring | execution lifecycle, freshness, attention state | none | Open monitoring or run detail | none |
| Tenant-local active-run cards and progress summaries | Tenant operator | Decide whether the highlighted active run is still ordinary progress | Compact run summary | Is this active work still healthy, or does it already need attention? | Run identity, active-state category, elapsed-state emphasis | Deep diagnostics, raw context, and reconciliation detail remain on run detail | execution lifecycle, freshness, active-state interpretation | none | Open run detail | none |
| Workspace operations list / monitoring rows | Workspace operator | Prioritize which active run deserves inspection first | Read-only monitoring registry | Which active runs are normal, and which are already late or likely stuck? | Run identity, scope, high-level lifecycle, and active-state emphasis | Raw payloads and detailed run history remain on run detail | lifecycle, freshness, problem emphasis | none | Open run detail, apply filters | none |
| Canonical operation run detail summary | Workspace operator | Confirm the meaning of the active-state issue before deeper diagnosis | Diagnostic detail surface | Why does this active run read as late or likely stuck, and what should I inspect next? | Top-level active-state explanation, scope, and summary guidance | Raw payloads, stack traces, and extended technical details | lifecycle, freshness, diagnostic readiness | none | Open related context or diagnostics | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes — one bounded derived active-state presentation contract expressed through existing presenter and active-run helpers rather than a standalone framework
- **New enum/state/reason family?**: yes — one derived operator-facing category family for `active / normal`, `active / past expectation`, `stale / likely stuck`, and `terminal / no longer active`, composed from existing freshness and problem-class truth
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators cannot trust compact active-work surfaces because they can understate or hide the difference between healthy progress and likely stuck work.
- **Existing structure is insufficient because**: Existing lifecycle truth lives in monitoring detail and backend services, but compact surfaces can still render active work as neutral `Queued` or `Running` states without exposing that the lifecycle expectation has already been exceeded.
- **Narrowest correct implementation**: Keep all backend lifecycle truth as-is, add one derived presentation contract, and retrofit only the existing tenant/dashboard/monitoring surfaces that already summarize active work.
- **Ownership cost**: Ongoing maintenance for one small presentation category family, cross-surface wording alignment, and regression coverage for fresh versus stale semantics.
- **Alternative intentionally rejected**: Introducing new `OperationRun` statuses or page-local stale heuristics was rejected because both would either widen persistence and lifecycle scope or create contradictory truth outside the existing lifecycle policy.
- **Release truth**: Current-release truth. This feature makes existing active-run observability honest now rather than preparing a future intervention framework.
### 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 proof burden is operator-visible active-state semantics across existing admin surfaces. Focused feature coverage is sufficient to prove fresh versus stale differentiation, tenant/workspace visibility boundaries, cross-surface consistency, and no false escalation without needing browser or heavy-governance lanes.
- **New or expanded test families**: Extend `tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, `tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`, `tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `tests/Feature/Filament/DashboardKpisWidgetTest.php`, `tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `tests/Feature/Monitoring/MonitoringOperationsTest.php`, `tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`.
- **Fixture / helper cost impact**: Moderate. Tests need representative `OperationRun` fixtures for healthy queued/running work, past-expectation work, likely stuck work, terminal transitions during navigation, mixed tenant visibility, and hidden-tenant/non-member isolation boundaries.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: monitoring-state-page
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, plus explicit proof that healthy active runs do not escalate falsely and that stale semantics remain consistent after drill-through from tenant surfaces into canonical monitoring.
- **Reviewer handoff**: Reviewers must confirm that the same active-state meaning appears on tenant cards, dashboard attention, operations rows, and canonical run detail; that no new backend status values or UI-only stale heuristics are introduced; and that hidden or out-of-scope runs do not influence visible summaries.
- **Budget / baseline / trend impact**: none
- **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/OpsUx/BulkOperationProgressDbOnlyTest.php tests/Feature/OpsUx/ProgressWidgetFiltersTest.php tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php tests/Feature/Filament/DashboardKpisWidgetTest.php tests/Feature/Filament/NeedsAttentionWidgetTest.php tests/Feature/Filament/WorkspaceOverviewOperationsTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RunAuthorizationTenantIsolationTest.php tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See unhealthy tenant activity without opening monitoring first (Priority: P1)
As a tenant operator, I want tenant-scoped activity surfaces to distinguish healthy active work from late or likely stuck work, so that I can notice unhealthy runs before manually opening the monitoring register.
**Why this priority**: Tenant cards and dashboard attention surfaces are the highest-frequency compact summaries for active work. If they remain misleading, the rest of the monitoring model is harder to trust.
**Independent Test**: Can be fully tested by seeding healthy and stale tenant-visible active runs, rendering the tenant dashboard and active-run cards, and verifying that only late or likely stuck work escalates while healthy work stays calm.
**Acceptance Scenarios**:
1. **Given** a tenant-visible run is queued or running within its expected lifecycle, **When** the tenant dashboard or active-run card renders, **Then** the run appears as healthy active work and does not escalate as stale or likely stuck.
2. **Given** a tenant-visible run is queued or running well past its expected lifecycle, **When** the same compact surfaces render, **Then** the run is visibly and linguistically distinct from healthy active work.
3. **Given** a run becomes terminal, **When** the tenant-scoped compact surfaces refresh, **Then** the run no longer appears as active work.
---
### User Story 2 - Scan problematic active runs in the canonical operations list (Priority: P1)
As a workspace operator, I want the canonical operations list to make problematic active runs obvious at row level, so that I can prioritize follow-up without opening each run.
**Why this priority**: The canonical operations list is the primary monitoring surface for run triage. If unhealthy active runs are not scanable there, operators lose the central prioritization surface.
**Independent Test**: Can be fully tested by seeding a mix of fresh and stale active runs across visible tenants and verifying that the operations list highlights problematic rows without falsely escalating healthy rows.
**Acceptance Scenarios**:
1. **Given** the operations list contains a mix of healthy active runs and likely stuck runs, **When** the workspace operator opens `/admin/operations`, **Then** the problematic active runs are immediately distinguishable at row level.
2. **Given** the operator enters `/admin/operations` from a tenant-scoped surface, **When** the canonical list opens with tenant context preserved, **Then** the same active-state semantics remain visible within that filtered monitoring slice.
3. **Given** an active run is fresh, **When** the operations list renders, **Then** it does not inherit the same escalation treatment as a late or likely stuck run.
---
### User Story 3 - Keep compact surfaces aligned with canonical run detail (Priority: P2)
As a workspace operator, I want canonical run detail to confirm the same active-state meaning that I saw on tenant and list surfaces, so that I can trust compact summaries without losing diagnostic depth.
**Why this priority**: The canonical detail page is the authoritative diagnostic surface. It must confirm, not contradict, the meaning shown elsewhere.
**Independent Test**: Can be fully tested by navigating from tenant-scoped or list surfaces into run detail for both healthy and stale runs and verifying that the same active-state meaning holds after drill-through.
**Acceptance Scenarios**:
1. **Given** a run reads as likely stuck on a tenant surface or list row, **When** the operator opens canonical run detail, **Then** the detail summary confirms that the run is past expectation or likely stuck before exposing raw diagnostics.
2. **Given** a run changes from active to terminal while the operator navigates between surfaces, **When** the compact surface and run detail refresh, **Then** neither surface continues to present the run as active.
3. **Given** a scheduled or initiator-null run becomes late, **When** the operator inspects it through monitoring, **Then** the active-state semantics remain truthful without implying a new notification channel or mutation behavior.
### Edge Cases
- A run may move from healthy to late or likely stuck between two page loads; the presentation contract must tolerate state changes without requiring a manual semantic reset.
- An operator may open the canonical run detail from a tenant-filtered monitoring slice; the run detail must remain authoritative while preserving scope context where already allowed.
- Multiple active runs may exist for one tenant; compact surfaces must not imply that all tenant activity is stale because one run is problematic.
- A run may become terminal while the operator is on a tenant dashboard or monitoring list; compact surfaces and run detail must converge on non-active presentation after refresh.
- Initiator-null scheduled work may become stale without producing a terminal DB notification; monitoring semantics must remain truthful without inventing a new scheduled-run notification contract.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no new write flow, and no new `OperationRun`. It changes only how existing run lifecycle and freshness truth are interpreted on admin-plane operator surfaces.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature introduces one derived active-state presentation contract because current-release operator trust requires it now. A narrower local patch is insufficient because the same truth must remain aligned across tenant cards, dashboard signals, list rows, and canonical detail. The addition stays derived, not persisted.
**Constitution alignment (XCUT-001):** This feature is cross-cutting across status messaging and dashboard/monitoring surfaces. It reuses the existing lifecycle, freshness, badge, and canonical monitoring paths, and it allows only one bounded deviation: same meaning, different density.
**Constitution alignment (TEST-GOV-001):** Focused feature tests on tenant dashboard surfaces, tenant active-run cards, monitoring rows, and run detail are the narrowest sufficient proof. No browser or heavy-governance lane is required.
**Constitution alignment (OPS-UX):** Existing Ops-UX 3-surface feedback remains unchanged. Toasts stay intent-only, progress surfaces remain the existing active-work surfaces, and terminal DB notifications stay unchanged. `OperationRun.status` and `OperationRun.outcome` transitions remain service-owned exclusively through `OperationRunService`. This feature must not introduce new `summary_counts` keys or any new scheduled-run notification semantics.
**Constitution alignment (RBAC-UX):** The feature operates only in the admin plane (`/admin` and `/admin/t/{tenant}/...`). Tenant-scoped compact surfaces remain tenant-entitlement safe, while canonical monitoring routes continue to enforce workspace membership and tenant entitlement on tenant-owned runs. No cross-plane visibility or raw capability checks may be added.
**Constitution alignment (BADGE-001):** Any changed status-like emphasis must continue to use centralized badge or status rendering. No page-local mapping from stale-detection inputs to color or label semantics is allowed.
**Constitution alignment (UI-FIL-001):** Touched surfaces must use native Filament widgets, tables, summaries, and existing shared status primitives. No custom local status markup or page-local color systems may replace shared run presentation.
**Constitution alignment (UI-NAMING-001):** Operator-facing language must use one canonical vocabulary for active-state meaning: healthy active work, past expected lifecycle, likely stuck, and no longer active. Implementation-first phrases or raw stale heuristics must not become primary labels.
**Constitution alignment (DECIDE-001):** The operations list remains the primary decision surface for run triage. Tenant dashboard surfaces remain secondary context surfaces, and canonical run detail remains the diagnostic surface. This feature must make those roles calmer and clearer, not create a new workbench.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** No new route, no new inspect model, and no new destructive action family are introduced. The canonical inspect model remains the run detail page. Compact surfaces may add emphasis and drill-through clarity only.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from `queued` and `running` alone is insufficient because lifecycle expectation and freshness change the operator meaning of active work. The feature adds one bounded derived interpretation layer and must prove business consequences across surfaces rather than only unit-testing one presenter.
### Functional Requirements
- **FR-001**: The system MUST derive one active-state presentation category for visible runs using existing lifecycle, freshness, and reconciliation truth.
- **FR-002**: The derived presentation contract MUST distinguish at least `active / normal`, `active / past expected lifecycle`, `stale / likely stuck`, and `terminal / no longer active`.
- **FR-003**: The feature MUST NOT introduce new `OperationRun.status` or `OperationRun.outcome` values to express those categories.
- **FR-004**: Tenant dashboard activity and attention surfaces MUST visibly and linguistically distinguish healthy active work from active work that is late or likely stuck.
- **FR-005**: Tenant-local active-run cards and progress summaries MUST surface the same active-state meaning as the tenant dashboard attention layer for the same run.
- **FR-006**: The workspace operations list MUST make late or likely stuck active runs distinguishable at row level without requiring drill-in.
- **FR-007**: Canonical run detail MUST explain the active-state category before exposing raw diagnostics, and that explanation MUST remain consistent with the compact surfaces that linked into it.
- **FR-008**: A run that is presented as late or likely stuck on canonical run detail MUST NOT appear as ordinary healthy active work on any covered tenant or monitoring surface.
- **FR-009**: Healthy active runs MUST NOT inherit stale or likely-stuck emphasis solely because they are `queued` or `running`.
- **FR-010**: When a run becomes terminal, covered compact surfaces MUST stop presenting it as active work on the next refresh cycle.
- **FR-011**: Existing lifecycle, freshness, and reconciliation logic MUST remain the source of truth; covered surfaces MUST NOT implement separate page-local stale heuristics.
- **FR-012**: When operators enter canonical monitoring from tenant context, existing tenant-prefilter continuity MAY be preserved, but the active-state semantics MUST remain identical to the unfiltered canonical list.
- **FR-013**: Covered surfaces MUST remain tenant-safe and workspace-safe; hidden runs or hidden tenants MUST NOT influence visible active-state summaries.
- **FR-014**: Scheduled or initiator-null runs MUST use the same active-state presentation rules as user-initiated runs where lifecycle and freshness truth are comparable.
- **FR-015**: This feature MUST NOT add retry, cancel, force-fail, reconcile-now, or other intervention actions to any covered surface.
- **FR-016**: Existing Ops-UX toast, progress-surface, and terminal-notification behavior MUST remain unchanged.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Tenant dashboard activity and attention surfaces | `/admin/t/{tenant}` | none added by this feature | Explicit dashboard CTA or linked run summary | none | none | none | n/a | n/a | No new mutation audit because the surface remains read-only | Action Surface Contract satisfied. Dashboard remains a signal surface, not a second workbench. UI-FIL-001 satisfied through native widgets and shared status primitives. |
| Tenant-local active-run cards and progress summaries | Tenant-scoped admin surfaces that already summarize active work | none added by this feature | Explicit linked run summary | none | none | none | n/a | n/a | No new mutation audit because the surface remains read-only | One primary inspect model remains the run detail. No redundant View action is added. |
| Workspace operations list | `/admin/operations` | Existing filters only; none added by this feature | Full-row open to run detail | none added by this feature | none | Existing list empty state unchanged | n/a | n/a | No new mutation audit because the surface remains read-only | Action Surface Contract satisfied. Row click stays the only primary inspect model. |
| Canonical operation run detail summary | `/admin/operations/{run}` | Existing safe/context actions unchanged | n/a | n/a | n/a | n/a | Existing detail/header actions unchanged | n/a | No new mutation audit because the feature changes summary semantics only | No exemption needed. This feature changes top-level explanation, not the action layout. |
### Key Entities *(include if feature involves data)*
- **Active-state presentation category**: A derived operator-facing category that explains whether visible active work is healthy, late, likely stuck, or no longer active.
- **Lifecycle expectation window**: The existing timing and policy truth that determines when active work has exceeded its normal expected lifecycle.
- **Stale active run**: An existing `OperationRun` whose lifecycle and freshness truth indicate that active work is likely stuck rather than merely still in progress.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In acceptance review, an operator can distinguish healthy tenant-visible active work from likely stuck work in one scan of the covered tenant surfaces.
- **SC-002**: 100% of covered fresh-versus-stale automated scenarios show the correct active-state category on the tenant dashboard, tenant active-run cards, and workspace operations list.
- **SC-003**: 100% of covered drill-through scenarios preserve the same active-state meaning between compact surfaces and canonical run detail.
- **SC-004**: 100% of covered healthy-active scenarios avoid false escalation when lifecycle expectation has not yet been exceeded.

View File

@ -0,0 +1,228 @@
# Tasks: Operation Run Active-State Visibility & Stale Escalation
**Input**: Design documents from `/specs/233-stale-run-visibility/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/operation-run-active-state-visibility.logical.openapi.yaml`, `quickstart.md`
**Tests**: Required. This feature changes runtime behavior across tenant progress surfaces, tenant dashboard summaries, workspace summaries, and canonical monitoring detail, so Pest coverage must be added or updated in `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`.
**Operations**: No new `OperationRun` is introduced. Existing lifecycle truth, reconciliation, toast/progress/terminal notification behavior, and service-owned status/outcome transitions must remain unchanged.
**RBAC**: The feature stays in the admin plane (`/admin` and `/admin/t/{tenant}/...`). It must preserve current tenant-entitlement and workspace-entitlement behavior, including non-member `404`, in-scope capability denial semantics, and tenant-safe summaries with no cross-tenant leakage.
**UI / Surface Guardrails**: The changed surfaces are native Filament widgets/resources/pages plus one existing Livewire progress component. The feature keeps `monitoring-state-page` coverage for canonical monitoring surfaces, uses `standard-native-filament` relief elsewhere, and remains `review-mandatory` because multiple existing operator surfaces must converge on the same truth.
**Filament UI Action Surfaces**: `RecentOperationsSummary`, tenant dashboard widgets, `OperationRunResource`, and `TenantlessOperationRunViewer` keep their existing inspect/open model. No new header, row, bulk, retry, cancel, or destructive actions are introduced.
**Badges**: Status-like semantics must stay on `BadgeCatalog` / `BadgeRenderer` and existing shared `OperationRun` presenter paths. No page-local stale badge mapping is allowed.
**Organization**: Tasks are grouped by user story so each slice is independently implementable and testable. Recommended delivery order is `US1 -> US2 -> US3` because tenant-surface honesty is the most urgent gap, canonical list scanability builds on the same truth, and detail-surface confirmation should close last against the final compact-surface semantics.
## Test Governance Checklist
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [X] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [X] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [X] Planned validation commands cover the change without pulling in unrelated lane cost.
- [X] The declared surface test profile or `standard-native-filament` relief is explicit.
- [X] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Shared Surface Scaffolding)
**Purpose**: Prepare the focused regression surfaces that will prove fresh-versus-stale semantics before runtime files are edited.
- [X] T001 [P] Extend stale-versus-fresh progress-overlay scaffolding in `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, and `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`
- [X] T002 [P] Extend tenant dashboard and tenant-summary semantics scaffolding in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
- [X] T003 [P] Extend workspace monitoring and visibility-safety scaffolding in `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
- [X] T004 [P] Extend canonical detail and drill-through continuity scaffolding in `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
**Checkpoint**: Focused tenant, workspace, and canonical test surfaces are ready to fail on stale-hidden regressions before implementation begins.
---
## Phase 2: Foundational (Blocking Truth And Shared Contract)
**Purpose**: Stabilize the one freshness-to-surface contract before any individual surface is changed.
**Critical**: No user story work should begin until this phase is complete.
- [X] T005 Freeze the canonical stale/fresh truth inputs and any needed thin derived adapter boundaries in `apps/platform/app/Models/OperationRun.php`, `apps/platform/app/Support/Operations/OperationRunFreshnessState.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/OpsUx/ActiveRuns.php`
- [X] T006 [P] Refresh the feature contract artifacts in `specs/233-stale-run-visibility/contracts/operation-run-active-state-visibility.logical.openapi.yaml` and `specs/233-stale-run-visibility/quickstart.md` so implementation and review language stay aligned with the finalized shared truth path
**Checkpoint**: The feature has one agreed freshness/problem-class/presenter contract and the docs match that contract before surface-by-surface retrofits begin.
---
## Phase 3: User Story 1 - See unhealthy tenant activity without opening monitoring first (Priority: P1) 🎯 MVP
**Goal**: Tenant-scoped dashboard and progress surfaces distinguish healthy active work from late or likely stuck work without inventing a second stale heuristic.
**Independent Test**: Seed fresh and stale tenant-visible queued/running runs, render tenant dashboard and progress surfaces, and verify that healthy work stays calm while stale-active work remains visible and elevated until the run becomes terminal.
### Tests for User Story 1
- [X] T007 [P] [US1] Add fresh-versus-stale tenant progress assertions, including polling continuity while only stale-active runs remain, in `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, and `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`
- [X] T008 [P] [US1] Add tenant summary and tenant dashboard copy/assertion coverage for calm-versus-elevated active work in `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
### Implementation for User Story 1
- [X] T009 [P] [US1] Update stale-active visibility and polling semantics in `apps/platform/app/Support/OpsUx/ActiveRuns.php`, `apps/platform/app/Livewire/BulkOperationProgress.php`, `apps/platform/resources/views/livewire/bulk-operation-progress.blade.php`, and `apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php`
- [X] T010 [P] [US1] Align tenant activity summaries with the shared presenter/badge truth in `apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php`, `apps/platform/resources/views/filament/widgets/tenant/recent-operations-summary.blade.php`, `apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php`, and `apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php`
- [X] T011 [US1] Tighten tenant-surface copy and density handling in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` and any touched shared badge mappings in `apps/platform/app/Support/Badges/Domains/OperationRunStatusBadge.php` without adding a second semantic framework
- [X] T012 [US1] Run the US1 tenant-surface verification flow from `specs/233-stale-run-visibility/quickstart.md`
**Checkpoint**: User Story 1 is independently functional and tenant operators can see unhealthy active work before opening canonical monitoring.
---
## Phase 4: User Story 2 - Scan problematic active runs in the canonical operations list (Priority: P1)
**Goal**: Workspace monitoring rows and workspace recent-operation summaries make problematic active runs obvious at scan time without falsely escalating fresh work.
**Independent Test**: Seed a mixed slice of fresh and stale active runs across visible tenants, open workspace summaries and `/admin/operations`, and verify that stale-active rows are immediately distinguishable while fresh active rows remain calm.
### Tests for User Story 2
- [X] T013 [P] [US2] Add workspace summary, recency, and visibility-safety assertions for fresh-versus-stale active work in `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
- [X] T014 [P] [US2] Add canonical operations-list assertions for row-level stale-active scanability and tenant-prefilter continuity in `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`
### Implementation for User Story 2
- [X] T015 [P] [US2] Align workspace summary payloads and view rendering in `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php`, `apps/platform/app/Filament/Widgets/Workspace/WorkspaceRecentOperations.php`, and `apps/platform/resources/views/filament/widgets/workspace/workspace-recent-operations.blade.php`
- [X] T016 [P] [US2] Tighten canonical list scanability and stale-active row semantics in `apps/platform/app/Filament/Widgets/Dashboard/RecentOperations.php` and `apps/platform/app/Filament/Resources/OperationRunResource.php`
- [X] T017 [US2] Reconcile any remaining stale-active badge/copy differences across workspace and canonical list surfaces in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` and `apps/platform/app/Support/Badges/Domains/OperationRunStatusBadge.php`
- [X] T018 [US2] Run the US2 workspace-list verification flow from `specs/233-stale-run-visibility/quickstart.md`
**Checkpoint**: User Story 2 is independently functional and workspace operators can scan the canonical monitoring list for unhealthy active work without opening every row.
---
## Phase 5: User Story 3 - Keep compact surfaces aligned with canonical run detail (Priority: P2)
**Goal**: Canonical run detail confirms the same active-state meaning that compact tenant and workspace surfaces already communicate, including terminal transitions and stale lineage.
**Independent Test**: Navigate from compact tenant/workspace surfaces into canonical run detail for fresh, stale, and reconciled-terminal runs, then verify that the top summary preserves the same meaning before deeper diagnostics render.
### Tests for User Story 3
- [X] T019 [P] [US3] Add canonical detail summary and stale-lineage assertions in `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php` and `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`
- [X] T020 [P] [US3] Add refresh-boundary and terminal-transition consistency assertions spanning compact-to-detail flows in `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php` and `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`
### Implementation for User Story 3
- [X] T021 [P] [US3] Align canonical detail summary copy and decision-zone truth in `apps/platform/app/Filament/Resources/OperationRunResource.php` and `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
- [X] T022 [US3] Align tenantless canonical viewer summary behavior and drill-through continuity in `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` and any touched related monitoring helpers under `apps/platform/app/Support/OperationRunLinks.php`
- [X] T023 [US3] Run the US3 compact-to-detail verification flow from `specs/233-stale-run-visibility/quickstart.md`
**Checkpoint**: User Story 3 is independently functional and canonical run detail confirms, rather than contradicts, the compact active-state meaning.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finalize documentation, formatting, and focused validation for the whole feature without widening scope.
- [X] T024 [P] Refresh `specs/233-stale-run-visibility/plan.md`, `specs/233-stale-run-visibility/research.md`, and `specs/233-stale-run-visibility/data-model.md` if implementation proves a thinner shared contract or adjusts touched file scope
- [X] T025 Run formatting on touched application and test files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [X] T026 Run the focused Pest suite from `specs/233-stale-run-visibility/quickstart.md` against `apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php`, `apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php`, `apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php`, `apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php`, `apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php`, `apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php`, `apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php`, `apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php`, `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php`, `apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php`, and `apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php`
- [X] T027 Record the finalized affected surfaces, any retained density-specific copy decisions, and the `document-in-feature` test-governance disposition in `specs/233-stale-run-visibility/plan.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the recommended first implementation increment.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and can begin after the shared truth contract is stable.
- **User Story 3 (Phase 5)**: Depends on User Stories 1 and 2 because canonical detail should be aligned against the final compact-surface semantics.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **US1 (P1)**: Starts immediately after Foundational and delivers the highest-value tenant-surface honesty fix.
- **US2 (P1)**: Can begin after Foundational, but is easiest to complete after US1 settles the compact stale-active vocabulary.
- **US3 (P2)**: Starts after US1 and US2 stabilize because canonical detail should confirm the final compact-surface contract, not compete with an in-progress one.
### Within Each User Story
- Story tests should be written and fail before the corresponding implementation tasks are considered complete.
- Shared files such as `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/ActiveRuns.php`, and `apps/platform/app/Filament/Resources/OperationRunResource.php` should be edited sequentially even when surrounding tasks are otherwise parallelizable.
- Each story's verification task should complete before moving to the next priority slice when working sequentially.
### Parallel Opportunities
- **Setup**: `T001`, `T002`, `T003`, and `T004` can run in parallel.
- **Foundational**: `T006` can run in parallel with the tail end of `T005` once the shared contract is clear.
- **US1 tests**: `T007` and `T008` can run in parallel.
- **US1 implementation**: `T009` and `T010` can run in parallel; `T011` should follow once the touched surface outputs are visible.
- **US2 tests**: `T013` and `T014` can run in parallel.
- **US2 implementation**: `T015` and `T016` can run in parallel; `T017` should follow once both summary and canonical list semantics are visible.
- **US3 tests**: `T019` and `T020` can run in parallel.
- **Polish**: `T024` can run in parallel with `T025` after runtime implementation is stable.
---
## Parallel Example: User Story 1
```bash
# Run tenant-surface test work in parallel:
T007 apps/platform/tests/Feature/OpsUx/BulkOperationProgressDbOnlyTest.php, apps/platform/tests/Feature/OpsUx/ProgressWidgetFiltersTest.php, apps/platform/tests/Feature/OpsUx/ProgressWidgetOverflowTest.php
T008 apps/platform/tests/Feature/Filament/RecentOperationsSummaryWidgetTest.php, apps/platform/tests/Feature/Filament/DashboardKpisWidgetTest.php, apps/platform/tests/Feature/Filament/NeedsAttentionWidgetTest.php, apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
# Then split the non-overlapping implementation work:
T009 apps/platform/app/Support/OpsUx/ActiveRuns.php, apps/platform/app/Livewire/BulkOperationProgress.php, apps/platform/resources/views/livewire/bulk-operation-progress.blade.php, apps/platform/resources/views/livewire/bulk-operation-progress-wrapper.blade.php
T010 apps/platform/app/Filament/Widgets/Tenant/RecentOperationsSummary.php, apps/platform/resources/views/filament/widgets/tenant/recent-operations-summary.blade.php, apps/platform/app/Filament/Widgets/Dashboard/DashboardKpis.php, apps/platform/app/Filament/Widgets/Dashboard/NeedsAttention.php
```
---
## Parallel Example: User Story 2
```bash
# Run workspace-summary, visibility-safety, and canonical-list assertions in parallel:
T013 apps/platform/tests/Feature/Filament/WorkspaceOverviewOperationsTest.php, apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php, apps/platform/tests/Feature/RunAuthorizationTenantIsolationTest.php, and apps/platform/tests/Feature/OpsUx/NonLeakageWorkspaceOperationsTest.php
T014 apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php and apps/platform/tests/Feature/Monitoring/OperationsDashboardDrillthroughTest.php
```
---
## Parallel Example: User Story 3
```bash
# Run canonical-detail and transition-consistency assertions in parallel:
T019 apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php and apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
T020 apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php and apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php
```
---
## Implementation Strategy
### First Implementation Increment (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate the feature with `T012` before widening the slice.
### Incremental Delivery
1. Stabilize the shared freshness/problem-class contract and docs.
2. Ship US1 to fix the tenant-surface blind spot and stale-hidden progress behavior.
3. Ship US2 to make workspace summaries and the canonical list scanable.
4. Ship US3 to ensure canonical detail confirms the same meaning after drill-through.
5. Finish with formatting, focused tests, and close-out notes.
### Parallel Team Strategy
With multiple developers:
1. One contributor can own Ops UX progress visibility while another extends tenant dashboard/widget assertions.
2. After Phase 2, one contributor can update workspace summary builders while another adjusts canonical list/detail semantics.
3. Keep `OperationUxPresenter.php`, `ActiveRuns.php`, and `OperationRunResource.php` serialized because they anchor the shared truth and surface contract.
---
## Notes
- `[P]` marks tasks that can run in parallel once prerequisites are satisfied and touched files do not overlap.
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
- The first working increment is Phase 1 through Phase 3, but the feature-complete approved scope remains Phase 1 through Phase 5 because canonical list and detail alignment are part of the accepted problem statement.
- All tasks above use exact repository paths and keep the work bounded to the existing admin-plane monitoring and progress surfaces.

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Dead Transitional Residue Cleanup
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated against the current cleanup sequencing in `docs/product/spec-candidates.md` and the active near-term roadmap focus in `docs/product/roadmap.md` on 2026-04-23.
- Scope stays intentionally narrow: remove dead residue first, defer schema removal and adjacent cleanup strands to follow-up specs.

View File

@ -0,0 +1,86 @@
# Phase 1 Data Model: Dead Transitional Residue Cleanup
## Overview
This feature introduces no new table, enum, or persisted artifact. It narrows the active runtime language around two already-existing truth domains:
1. Baseline profile lifecycle truth should flow only through `BaselineProfileStatus`.
2. Tenant app-status should remain, at most, historical stored data, not active runtime/support truth.
## Persistent Source Truths
### BaselineProfile
**Purpose**: Workspace-owned baseline profile record.
**Key fields**:
- `id`
- `workspace_id`
- `name`
- `status`
- `capture_mode`
- `active_snapshot_id`
**Validation rules**:
- `status` is cast to `BaselineProfileStatus` and is the only active lifecycle contract for draft, active, and archived behavior.
- Deprecated alias constants on the model are not part of persistent truth and can be removed once no runtime caller depends on them.
### Tenant
**Purpose**: Tenant-owned lifecycle and management record.
**Key fields**:
- `id`
- `workspace_id`
- `name`
- `status`
- `rbac_status`
- `app_status` (historical legacy field)
**Validation rules**:
- `status` remains the active tenant lifecycle truth.
- `rbac_status` remains a separate active management truth.
- `app_status` may remain stored historically, but current runtime/support paths must not treat it as active default truth.
## Support Artifacts In Scope
### Deprecated alias layer
**Artifact**: `BaselineProfile::STATUS_DRAFT`, `STATUS_ACTIVE`, `STATUS_ARCHIVED`
**Role after cleanup**:
- removed from active runtime language
### Legacy badge layer
**Artifacts**:
- `BadgeDomain::TenantAppStatus`
- `BadgeCatalog` mapper entry for tenant app status
- `TenantAppStatusBadge`
**Role after cleanup**:
- removed if no runtime consumer remains
### Legacy default setup
**Artifacts**:
- `TenantFactory` default `app_status => 'ok'`
- `SeedBackupHealthBrowserFixture` default `app_status => 'ok'`
**Role after cleanup**:
- removed as ambient defaults
- legacy `app_status` becomes explicit per-test or per-scenario setup only
## Behavioral Rules
1. Removing dead residue must not change baseline profile archive/list/workspace behavior.
2. Removing dead residue must not change tenant lifecycle or RBAC truth behavior.
3. Tests that still need a legacy `app_status` value must set it explicitly in the scenario.
4. Historical schema and migrations remain historical artifacts, not cleanup targets in this slice.
## No New Persistence
- No new table is introduced.
- No new enum or reason family is introduced.
- No new derived readiness or cleanup artifact is introduced.
- No stored field is repurposed into a new active truth contract.

View File

@ -0,0 +1,245 @@
# Implementation Plan: Dead Transitional Residue Cleanup
**Branch**: `234-dead-transitional-residue` | **Date**: 2026-04-23 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/234-dead-transitional-residue/spec.md`
**Note**: This plan keeps historical tenant `app_status` storage and historical migrations intact. It removes only dead runtime alias/support paths and tightens fixtures/tests so legacy values become explicit opt-in setup rather than ambient repo truth.
## Summary
Remove the dead `BaselineProfile::STATUS_*` alias layer and retire tenant app-status residue from the centralized badge catalog, default test fixtures, browser smoke seed data, and legacy-facing tests. The implementation stays intentionally small: no schema change, no new status family, no operator-surface redesign, and no compatibility shim. The proof burden is that current tenant truth and baseline profile behavior remain unchanged while the dead semantics disappear from active runtime language.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
**Storage**: Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice
**Testing**: Pest v4 feature and unit tests through Laravel Sail
**Validation Lanes**: `fast-feedback`, `confidence`
**Target Platform**: Laravel admin web application in Sail containers with admin routes under `/admin`
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
**Performance Goals**: Preserve current request/query behavior; cleanup must not add runtime branching, new queries, or new UI layers
**Constraints**: No schema change, no compatibility aliases, no new badge/readiness domain, no authorization changes, no global-search broadening, and no new operator-facing surface
**Scale/Scope**: One Eloquent model, one central badge registry path, one legacy badge mapper, one tenant factory, one browser fixture command, and focused tenant/baseline regression families
## Filament v5 Implementation Contract
- **Livewire v4.0+ compliance**: Preserved. The cleanup does not introduce any legacy Livewire patterns and does not add new Filament component types.
- **Provider registration location**: Unchanged. Panel providers remain registered in `bootstrap/providers.php`.
- **Global search coverage**: `TenantResource` remains globally searchable and already has View/Edit pages. `BaselineProfileResource` keeps global search disabled via `$isGloballySearchable = false` and already has View/Edit pages. This cleanup adds no new global-search exposure.
- **Destructive actions**: No destructive action is introduced or changed. Existing tenant and baseline profile destructive flows remain on their current confirmation and authorization paths.
- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when future work adds registered assets.
- **Testing plan**: Prove the cleanup with focused feature tests for tenant-truth continuity and baseline-profile list/view/edit/archive continuity, plus unit coverage for central badge-catalog cleanup.
## UI / Surface Guardrail Plan
- **Guardrail scope**: no operator-facing surface change
- **Native vs custom classification summary**: `N/A`
- **Shared-family relevance**: none
- **State layers in scope**: none
- **Handling modes by drift class or surface**: `report-only`
- **Repository-signal treatment**: `review-mandatory` because this is a repo-hygiene cleanup that removes active residue rather than hiding it
- **Special surface test profiles**: `N/A`
- **Required tests or manual smoke**: `functional-core`
- **Exception path and spread control**: none
- **Active feature PR close-out entry**: `Guardrail`
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: no
- **Systems touched**: centralized badge registry, baseline profile status model language, tenant default fixtures, browser smoke fixture command, and focused tenant/baseline regression files
- **Shared abstractions reused**: `BaselineProfileStatus`, `BadgeCatalog`, `BadgeDomain`, and the existing tenant/baseline regression families
- **New abstraction introduced? why?**: none
- **Why the existing abstraction was sufficient or insufficient**: Existing abstractions are sufficient because this work removes dead support paths instead of creating a new semantic or presentation layer.
- **Bounded deviation / spread control**: none
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with no new persistence, no new semantic family, and no operator-surface expansion.*
| Gate | Status | Plan Notes |
|------|--------|------------|
| Inventory-first / read-write separation | PASS | No Graph path, no new mutation flow, and no snapshot or restore behavior change. |
| RBAC, workspace isolation, tenant isolation | PASS | No route, policy, capability, or resource visibility behavior changes are planned. |
| Run observability / Ops-UX lifecycle | PASS | No `OperationRun` is created or modified; this cleanup is outside queued or remote execution semantics. |
| Shared pattern first | PASS | The plan simplifies the central badge path by removing an unused legacy domain rather than bypassing it with a local mapping. |
| Proportionality / no premature abstraction | PASS | The plan removes existing residue and introduces no new abstraction, enum, registry, or persistence. |
| Persisted truth / behavioral state | PASS | Historical columns and migrations remain untouched; no new state or artifact is introduced. |
| Badge semantics / Filament-native discipline | PASS | Central badge semantics remain authoritative, and no page-local or view-local replacement badge language is added. |
| Filament v5 / Livewire v4 contract | PASS | Existing resources remain unchanged in behavior; provider registration and global-search posture stay compliant. |
| Test governance | PASS | Proof stays in focused feature/unit lanes with no heavy-family promotion and a net reduction in fixture-default ambiguity. |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for tenant-truth and baseline-profile continuity; `Unit` for central badge-catalog cleanup
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The business truth is continuity after residue removal. Feature tests prove runtime behavior on current tenant and baseline flows, while one small unit slice proves the central badge cleanup without widening into browser or heavy-governance coverage.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileArchiveActionTest.php tests/Feature/Filament/BaselineProfileListFiltersTest.php tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php tests/Unit/Badges/BadgeCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd /Users/ahmeddarrazi/Documents/projects/wt-plattform && rg -n "BaselineProfile::STATUS_|TenantAppStatus|tenant_app_status" apps/platform/app apps/platform/tests apps/platform/database/factories && rg -n -- "app_status" apps/platform/app apps/platform/tests apps/platform/database/factories`
- **Fixture / helper / factory / seed / context cost risks**: Low to moderate. Removing the default `app_status` from `TenantFactory` and browser fixture setup can expose hidden reliance on ambient legacy values in tests or smoke commands.
- **Expensive defaults or shared helper growth introduced?**: No. The change reduces a misleading default rather than adding a new helper burden.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `standard-native relief`
- **Closing validation and reviewer handoff**: Reviewers should verify that removed residue had no active runtime dependency, that tenant/baseline tests still pass without ambient legacy defaults, and that no migration or new compatibility path slipped in.
- **Budget / baseline / trend follow-up**: none
- **Review-stop questions**: Did any test or fixture still need `app_status` but fail to set it explicitly? Did badge cleanup remove a still-used domain? Did alias removal trigger any runtime or static reference outside the planned scope? Did the cleanup expand into schema or route changes?
- **Escalation path**: `document-in-feature`
- **Active feature PR close-out entry**: `Guardrail`
- **Why no dedicated follow-up spec is needed**: This is routine current-release cleanup. A follow-up spec is only needed if a hidden runtime dependency forces a broader domain decision rather than simple residue removal.
## Project Structure
### Documentation (this feature)
```text
specs/234-dead-transitional-residue/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── checklists/
│ └── requirements.md
└── tasks.md
```
No contracts artifact is planned because this cleanup changes no route, API, or standalone logical interaction contract.
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Console/Commands/
│ │ └── SeedBackupHealthBrowserFixture.php
│ ├── Models/
│ │ └── BaselineProfile.php
│ └── Support/Badges/
│ ├── BadgeCatalog.php
│ ├── BadgeDomain.php
│ └── Domains/
│ └── TenantAppStatusBadge.php
├── database/
│ └── factories/
│ └── TenantFactory.php
└── tests/
├── Feature/
│ ├── Baselines/
│ │ ├── BaselineProfileArchiveActionTest.php
│ │ ├── BaselineProfileAuthorizationTest.php
│ │ └── BaselineProfileWorkspaceOwnershipTest.php
│ └── Filament/
│ ├── BaselineProfileListFiltersTest.php
│ ├── BaselineProfileScopeV2PersistenceTest.php
│ ├── TenantLifecycleStatusDomainSeparationTest.php
│ └── TenantTruthCleanupSpec179Test.php
└── Unit/
└── Badges/
├── BadgeCatalogTest.php
└── TenantBadgesTest.php
```
**Structure Decision**: Keep the work entirely inside the existing Laravel application in `apps/platform`. The plan updates one model, one central badge path, one default tenant factory, one browser fixture command, and focused regression files rather than touching resource layout or introducing a cleanup subsystem.
## Complexity Tracking
No constitutional violation is planned. No new structure is introduced, so no complexity exception is required.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Proportionality Review
> No new enum, persisted entity, abstraction layer, taxonomy, or cross-domain UI framework is planned in this slice.
- **Current operator problem**: Dead alias and legacy-support residue still make it easy for contributors and tests to treat retired semantics as current repo truth.
- **Existing structure is insufficient because**: The current code already has the canonical `BaselineProfileStatus` path and current tenant-truth behavior, but dead support artifacts continue to conserve the older semantics around them.
- **Narrowest correct implementation**: Remove only the dead alias/support paths and make any still-needed legacy values explicit in the few tests or fixtures that truly need them.
- **Ownership cost created**: Small one-time cleanup across a handful of files, followed by lower ongoing cognitive and maintenance cost.
- **Alternative intentionally rejected**: Keeping deprecated aliases, badge domains, or factory defaults for convenience was rejected because this is a pre-production repo and the residue already undermines cleanup discipline.
- **Release truth**: Current-release truth cleanup.
## Phase 0 Research Summary
- `BaselineProfile::STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` exist only in `apps/platform/app/Models/BaselineProfile.php`; no current `apps/platform` runtime or test reference still needs them.
- The tenant app-status badge path is now pure residue: `BadgeDomain::TenantAppStatus`, the `BadgeCatalog` mapper entry, and `TenantAppStatusBadge` remain, but the only confirmed consumers are badge tests, not current runtime surfaces.
- `TenantFactory` still defaults `app_status` to `ok`, and `SeedBackupHealthBrowserFixture` still writes `app_status => 'ok'`, which keeps a retired value ambient in test and smoke data even though current tenant surfaces no longer depend on it.
- Existing tenant-truth regressions intentionally set `app_status` in a few scenarios to prove suppression. Those explicit setups should remain where meaningful; only ambient defaults should go away.
- Historical migrations and historical stored columns are not part of this cleanup. The correct scope is runtime/support residue removal first, not schema deletion.
## Phase 1 Design Summary
- `research.md` records the cleanup decisions and rejected alternatives.
- `data-model.md` documents the still-active persistent truths and the support artifacts that should stop acting as active repo truth.
- `quickstart.md` gives the narrow validation order for alias removal, badge cleanup, fixture cleanup, and regression verification.
- No contracts artifact is created because the feature changes no route, API, or new user interaction contract.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Implementation Strategy
### Phase A — Remove dead baseline profile alias language
**Goal**: Make `BaselineProfileStatus` the only active baseline profile status contract.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Models/BaselineProfile.php` | Remove deprecated `STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` constants. |
| A.2 | Targeted grep + baseline regression files | Confirm no runtime or test path in `apps/platform` still references the removed aliases; keep baseline profile behavior proved through existing feature tests rather than adding a new alias-specific shim. |
### Phase B — Retire the dead tenant app-status badge path centrally
**Goal**: Remove the last active runtime support entry point for tenant app-status semantics.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/Badges/BadgeDomain.php` | Remove the `TenantAppStatus` enum case if no active runtime consumer remains. |
| B.2 | `apps/platform/app/Support/Badges/BadgeCatalog.php` | Remove the `TenantAppStatus` mapper registration. |
| B.3 | `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php` | Remove the mapper class once the registry path is gone. |
| B.4 | `apps/platform/tests/Unit/Badges/TenantBadgesTest.php` and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php` | Update badge coverage to prove the canonical tenant lifecycle/RBAC/permission domains still work and that the removed legacy domain no longer acts as active repo truth. |
### Phase C — Make legacy app-status explicit instead of ambient in defaults and smoke data
**Goal**: Stop silently injecting retired tenant app-status semantics into factories and browser fixture setup.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/database/factories/TenantFactory.php` | Remove the default `app_status => 'ok'` assignment so tests must opt in explicitly when they need a historical value. |
| C.2 | `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php` | Remove or conditionalize the forced `app_status => 'ok'` write unless the scenario explicitly requires it for a still-active smoke purpose. |
| C.3 | Targeted tenant-truth tests | Keep explicit `app_status` setup only in cases that intentionally prove the legacy field no longer surfaces as truth. |
### Phase D — Rebalance regression coverage around explicit legacy setup
**Goal**: Preserve current behavior while making the cleanup durable.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` | Keep explicit legacy `app_status` values where they prove suppression; stop depending on factory defaults. |
| D.2 | `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php` | Keep lifecycle/RBAC separation assertions intact with explicit historical setup where needed. |
| D.3 | `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` | Use these existing baseline profile tests as continuity proof after alias removal across archive, list/filter, view/edit, and workspace-owned behavior. |
## Risks and Mitigations
- **Hidden dependency on removed badge domain**: A helper or test outside the initial grep scope may still call `BadgeDomain::TenantAppStatus`. Mitigation: targeted grep before merge plus running `TenantBadgesTest` and `BadgeCatalogTest` after central removal.
- **Ambient fixture reliance on `app_status`**: Removing the factory default can reveal tests or smoke commands that only passed because `app_status` was silently set to `ok`. Mitigation: convert those cases to explicit setup rather than restoring the default.
- **Baseline alias removal reaches farther than expected**: A non-obvious reference could still exist outside the model. Mitigation: grep for `BaselineProfile::STATUS_` before merge and rely on existing baseline feature tests for continuity.
- **Cleanup scope drifts into schema deletion**: The presence of migrations and stored columns can tempt a larger cut. Mitigation: keep historical schema/migrations explicitly out of scope in both plan and tasks.
## Post-Design Re-check
The plan remains constitution-compliant, Livewire v4 / Filament v5 compliant, and appropriately narrow. It removes dead runtime/support residue, preserves existing tenant and baseline behavior, introduces no new persistence or abstraction, and is ready for `/speckit.tasks`.
## Implementation Close-Out
- **Residue search result**: Clean for active runtime/support paths. Remaining `tenant_app_status` and `BaselineProfile::class.'::STATUS_*'` matches are negative regression assertions only.
- **Remaining `app_status` usage**: Explicit historical setup and suppression assertions in tenant-truth tests only. `TenantFactory` and the backup-health browser fixture no longer write ambient `app_status` defaults.
- **Follow-up decision**: No hidden runtime dependency was found, so no follow-up cleanup spec is needed for this slice.

View File

@ -0,0 +1,80 @@
# Quickstart: Dead Transitional Residue Cleanup
## Goal
Validate that dead baseline profile alias language and dead tenant app-status support residue are removed without changing current tenant-truth or baseline-profile behavior.
## Prerequisites
1. Start Sail for `apps/platform`.
2. Ensure the current branch is `234-dead-transitional-residue`.
3. Be ready to update tests that intentionally use historical `app_status` values so they set those values explicitly.
## Implementation Validation Order
### 1. Format touched files
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
Expected outcome:
- Touched PHP and test files follow project formatting rules.
### 2. Run tenant-truth continuity coverage
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantTruthCleanupSpec179Test.php tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
```
Expected outcome:
- Tenant lifecycle and RBAC truth remain unchanged.
- Any legacy `app_status` used in those tests is explicit scenario setup, not a hidden factory default.
### 3. Run baseline-profile continuity coverage
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileArchiveActionTest.php tests/Feature/Filament/BaselineProfileListFiltersTest.php tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php tests/Feature/Baselines/BaselineProfileAuthorizationTest.php
```
Expected outcome:
- Baseline profile archive, list, view, and edit behavior still work after removing deprecated status aliases.
### 4. Run central badge cleanup coverage
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php tests/Unit/Badges/BadgeCatalogTest.php
```
Expected outcome:
- The central badge catalog still resolves active tenant badge domains correctly.
- The removed tenant app-status badge path no longer acts as active runtime truth.
### 5. Run a focused residue grep before merge
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd /Users/ahmeddarrazi/Documents/projects/wt-plattform && rg -n "BaselineProfile::STATUS_|TenantAppStatus|tenant_app_status" apps/platform/app apps/platform/tests apps/platform/database/factories
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd /Users/ahmeddarrazi/Documents/projects/wt-plattform && rg -n -- "app_status" apps/platform/app apps/platform/tests apps/platform/database/factories
```
Expected outcome:
- No unexpected alias or badge-domain references remain.
- Any remaining `app_status` matches are deliberate explicit historical setup or reviewed historical artifacts, not ambient defaults or active truth.
Implementation close-out:
- Active runtime/support paths are clean.
- Remaining `tenant_app_status` and `BaselineProfile::class.'::STATUS_*'` matches are negative regression assertions.
- Remaining `app_status` matches are explicit tenant-truth setup or suppression assertions; no follow-up spec is needed.
## Optional Manual Smoke
1. Open `/admin/tenants` and verify current tenant truth still behaves as before.
2. Open `/admin/baseline-profiles`, then a baseline profile view page and edit page, and verify list, view, edit, and archive behavior still read normally.
3. If the backup-health browser fixture command is still used locally, run it once and confirm it no longer depends on ambient `app_status` defaults.
## Non-Goals For This Slice
- No database migration.
- No route or global-search change.
- No new readiness or badge framework.
- No onboarding or provider-connection cleanup outside the approved dead-residue scope.

View File

@ -0,0 +1,41 @@
# Phase 0 Research: Dead Transitional Residue Cleanup
## Decision: Remove the deprecated `BaselineProfile::STATUS_*` aliases entirely
**Rationale**: The only confirmed definitions of `BaselineProfile::STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` are in `apps/platform/app/Models/BaselineProfile.php`. No current `apps/platform` runtime or test reference still depends on those aliases. The canonical contract is already the `BaselineProfileStatus` enum cast, so keeping the constants adds dead language without serving current behavior.
**Alternatives considered**:
- Keep the aliases but leave them deprecated: rejected because they no longer protect any active caller and continue to advertise parallel truth.
- Replace them with forwarding helpers: rejected because that would add new residue to preserve dead semantics.
## Decision: Remove the tenant app-status badge domain from the central badge path
**Rationale**: The remaining runtime path for tenant app-status semantics is the central badge registry: `BadgeDomain::TenantAppStatus`, the `BadgeCatalog` mapper entry, and `TenantAppStatusBadge`. Current confirmed consumers are badge tests, not active tenant surfaces. Once the legacy badge domain has no runtime consumer, removing it centrally is cleaner than keeping it as diagnostic folklore.
**Alternatives considered**:
- Keep the badge domain as a dormant diagnostic mapping: rejected because no current runtime surface needs it and dormant central mappings make it easier to reintroduce dead semantics accidentally.
- Move the mapping into a test helper: rejected because test-only preservation would still keep the dead semantics alive as sanctioned language.
## Decision: Remove ambient `app_status` defaults from test and smoke setup
**Rationale**: `TenantFactory` still defaults `app_status` to `ok`, and `SeedBackupHealthBrowserFixture` still writes `app_status => 'ok'`. That keeps a retired value ambient in new tenant records and smoke data even though current tenant surfaces no longer depend on it. The safer contract is explicit legacy setup only where a test or fixture intentionally proves suppression.
**Alternatives considered**:
- Keep the default for convenience: rejected because convenience is exactly how dead semantics keep surviving.
- Remove `app_status` from every explicit test and fixture immediately: rejected because a few tests intentionally set historical values to prove they no longer surface as truth.
## Decision: Keep historical schema and stored fields out of scope
**Rationale**: The repo still contains historical migrations and the stored `tenants.app_status` column. This cleanup is about active runtime/support residue, not schema deletion. Removing columns or historical migrations would widen the slice beyond the approved cleanup boundary.
**Alternatives considered**:
- Drop the column now: rejected because the spec explicitly forbids schema work in this slice.
- Add a migration shim or deprecation wrapper: rejected because this is pre-production cleanup, not a compatibility exercise.
## Decision: Reuse existing tenant-truth and baseline-profile regressions instead of creating a new cleanup harness
**Rationale**: The current proof burden is continuity after residue removal. Existing tenant-truth feature tests and baseline-profile feature tests already exercise the active behavior we need to protect. A small badge-catalog unit slice is enough for the central registry cleanup. A new meta guard framework would add more long-term burden than value.
**Alternatives considered**:
- Add grep-driven guard tests for every removed symbol: rejected because behavior-facing tests are the primary proof and repo grep is sufficient as a review aid.
- Rely on manual inspection only: rejected because cleanup regressions are easy to reintroduce silently.

View File

@ -0,0 +1,223 @@
# Feature Specification: Dead Transitional Residue Cleanup
**Feature Branch**: `234-dead-transitional-residue`
**Created**: 2026-04-23
**Status**: Draft
**Input**: User description: "Dead Transitional Residue Cleanup"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: The repo still carries dead transitional residue around tenant truth and baseline profile status language. Deprecated baseline profile status aliases and retired tenant app-status support artifacts still survive in support code, fixtures, and tests even though the current product no longer treats them as active truth.
- **Today's failure**: Contributors and regressions can still conserve or reintroduce dead semantics because the repo keeps them available as if they were valid current-release language. That weakens earlier tenant-truth cleanup and makes follow-up cleanup strands harder to land cleanly.
- **User-visible improvement**: Existing tenant and baseline profile surfaces keep the same current truth, but retired app-status and deprecated status-alias semantics stop leaking back through defaults, badges, fixtures, and tests.
- **Smallest enterprise-capable version**: Remove dead baseline profile status aliases and tenant app-status residue from active runtime support code, factories, seeds, fixtures, and tests after verifying no productive dependency still exists. Do not redesign tenant readiness, baseline semantics, or storage.
- **Explicit non-goals**: No new readiness model, no new status family, no schema redesign, no provider-connection cleanup beyond the dead tenant app-status residue, no onboarding fallback cleanup, and no canonical operation-type convergence work.
- **Permanent complexity imported**: None. The feature reduces permanent complexity by removing dead symbols, dead badge semantics, and fixture conservatism while keeping focused regression coverage.
- **Why now**: This is the first step in the active repository cleanup strand. Leaving dead residue in place makes the next cleanup slices and source-of-truth work riskier because they must keep fighting old semantics that should already be gone.
- **Why not local**: Deleting only one constant or one test would leave the same dead semantics alive in other seams such as badge registration, factories, browser fixtures, or seed data. The problem is distributed residue, not one stray reference.
- **Approval class**: Cleanup
- **Red flags triggered**: One mild red flag: the cleanup spans model, badge, fixture, seed, and test seams. Defense: those seams all conserve the same retired semantics, so one bounded cleanup spec is smaller and safer than several micro-specs.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace + tenant
- **Primary Routes**:
- `/admin/tenants`
- `/admin/tenants/{tenant}`
- `/admin/baseline-profiles`
- `/admin/baseline-profiles/{profile}`
- `/admin/baseline-profiles/{profile}/edit`
- **Data Ownership**:
- `tenants` remain the tenant-owned source of lifecycle, provider, and RBAC truth. This feature does not add, remove, or reinterpret tenant lifecycle semantics.
- `baseline_profiles` remain the workspace-owned source of baseline profile truth. This feature does not change profile lifecycle behavior; it removes deprecated alias language around that existing truth.
- No new persisted truth, mirror field, or cleanup ledger is introduced.
- **RBAC**:
- Workspace membership remains required for the affected admin resources.
- Tenant isolation and current capability checks remain unchanged.
- This feature does not broaden visibility, alter 404 versus 403 semantics, or add new authorization paths.
## 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`)*
N/A - no shared interaction family touched.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
N/A - no operator-facing surface change. Existing tenant and baseline profile surfaces must keep their current behavior while dead supporting residue is removed.
## 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**: Dead residue keeps retired semantics available, which makes it easier for tests, fixtures, and future changes to treat them as current truth again.
- **Existing structure is insufficient because**: The existing structure is the problem; it still contains aliases and support artifacts that no longer represent active product language.
- **Narrowest correct implementation**: Remove only the dead residue that has no active runtime contract and update the focused regressions that still conserve it.
- **Ownership cost**: One bounded cleanup pass across affected support code, fixtures, and tests, followed by lower long-term cognitive and maintenance cost.
- **Alternative intentionally rejected**: Leaving deprecated aliases and legacy support artifacts in place "just in case" was rejected because this is a pre-production repo and the residue already causes semantic drift.
- **Release truth**: Current-release truth cleanup.
### 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 proof burden is that existing tenant and baseline profile behaviors still work after dead residue is removed, and that dead semantics no longer survive in active support paths. Focused feature coverage with one small badge-regression slice is sufficient.
- **New or expanded test families**: Update the existing tenant-truth cleanup regressions, tenant lifecycle domain-separation regressions, baseline profile behavior regressions, and badge catalog regressions. Add a narrow baseline-status cleanup regression only if an existing file cannot express the assertion cleanly.
- **Fixture / helper cost impact**: Lower overall. Factories, seeds, and browser fixtures should stop carrying dead app-status defaults unless a still-active boundary proves they are needed.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient. The cleanup must also prove that central badge registration and default fixtures do not conserve retired semantics.
- **Reviewer handoff**: Reviewers must confirm that no active runtime dependency still needs the removed residue, that tenant and baseline profile behavior remains unchanged for current truth, and that dead semantics are removed rather than rewrapped in a compatibility shim.
- **Budget / baseline / trend impact**: none
- **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/Filament/TenantTruthCleanupSpec179Test.php tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineProfileArchiveActionTest.php tests/Feature/Filament/BaselineProfileListFiltersTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/TenantBadgesTest.php tests/Unit/Badges/BadgeCatalogTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Keep tenant truth free of retired app-status semantics (Priority: P1)
As an operator, I can continue to use tenant surfaces without retired app-status semantics resurfacing through defaults, badges, or seeded examples, so the current lifecycle, provider, and RBAC truth stays trustworthy.
**Why this priority**: Tenant truth cleanup already happened on primary surfaces. The highest-value part of this cleanup is making sure dead supporting residue cannot silently undo that work.
**Independent Test**: Can be fully tested by exercising the existing tenant list and tenant detail regressions with records that still contain legacy app-status values and proving that current tenant truth stays unchanged.
**Acceptance Scenarios**:
1. **Given** a tenant record still stores a legacy app-status value, **When** the operator opens the existing tenant list or tenant detail surface, **Then** that legacy value does not regain current-status meaning.
2. **Given** seeded or factory-created tenant examples are used in current tenant-truth regressions, **When** those regressions run after cleanup, **Then** they no longer depend on app-status defaults to make the surfaces work.
3. **Given** lifecycle, provider, and RBAC truth already coexist on a tenant surface, **When** the cleanup is complete, **Then** those active truths remain separate and unchanged.
---
### User Story 2 - Use one baseline profile status language (Priority: P1)
As a maintainer, I can reason about baseline profile state through one canonical status contract, so draft, active, and archived behavior is not split between live status truth and deprecated aliases.
**Why this priority**: The deprecated baseline profile aliases are explicitly dead residue. Removing them is the cleanest proof that the repo now has one active baseline profile status language.
**Independent Test**: Can be fully tested by running existing baseline profile archive, list/filter, and view/edit continuity regressions after the deprecated alias language is removed and confirming that current baseline profile behavior stays intact.
**Acceptance Scenarios**:
1. **Given** baseline profiles still move through draft, active, and archived behavior today, **When** existing baseline profile regressions run after cleanup, **Then** the behavior still works without deprecated status aliases.
2. **Given** a contributor updates baseline profile logic or tests, **When** they read current profile status semantics, **Then** only the canonical status contract is available as active language.
3. **Given** an operator opens or saves an existing baseline profile through the current view and edit surfaces, **When** the cleanup is complete, **Then** those surfaces continue to render and persist through the canonical status contract without depending on deprecated aliases.
---
### Cross-Cutting Verification - Prove the residue is fully retired (Release Gate)
As a reviewer, I can verify the cleanup in one focused pass, so the repo does not keep half-dead semantics alive in support code, fixtures, or tests.
**Why this priority**: Cleanup value is only real if the dead semantics are actually gone rather than merely hidden in one layer.
**Release Gate**: This verification runs after User Story 1 and User Story 2 are complete and confirms that the touched runtime and test paths no longer expose the retired semantics as active language.
**Note**: This is not an independently shippable MVP slice; it is the feature-level closeout check that proves the cleanup is complete.
**Acceptance Scenarios**:
1. **Given** the cleanup branch, **When** the reviewer runs the focused validation commands, **Then** current tenant and baseline profile behaviors still pass without the retired residue.
2. **Given** a touched support path, fixture, or test previously conserved dead semantics, **When** the cleanup is reviewed, **Then** that path is either removed, rewritten to current truth, or explicitly deferred as a follow-up rather than silently preserved.
### Edge Cases
- A legacy storage field may still exist historically or in migrations even when it no longer has any active runtime meaning.
- A browser fixture or seeder may still populate a retired value for historical realism; this spec must remove mandatory dependency on that value without changing unrelated fixture intent.
- Some tests may use literal status values rather than deprecated aliases; the cleanup must distinguish current canonical value usage from dead alias usage.
- Central badge registration may still contain dormant legacy entries even when no current surface consumes them; dormant entries count as cleanup scope if they no longer support active truth.
- Baseline profile archive, list, view, and edit behavior must continue to work because the active status contract already exists independently of the deprecated aliases.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature adds no Microsoft Graph calls, no long-running work, and no new write workflow. It is a bounded runtime and test cleanup over existing tenant and baseline profile truth.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces no new persistence, abstraction, state family, or semantic layer. It removes dead structure that no longer represents current-release truth.
**Constitution alignment (XCUT-001):** Not applicable. The feature does not add or modify a shared operator interaction family.
**Constitution alignment (TEST-GOV-001):** Focused feature and badge-regression coverage is the narrowest sufficient proof because the business risk is dead semantics surviving in active support paths, not a new workflow or surface family.
**Constitution alignment (RBAC-UX):** No authorization behavior changes. Existing workspace membership, tenant isolation, and capability enforcement remain authoritative.
**Constitution alignment (BADGE-001):** Centralized badge semantics remain authoritative. If the retired tenant app-status badge domain has no active consumer, it must be removed centrally rather than replaced with any page-local or test-local mapping.
**Constitution alignment (UI-FIL-001):** No new Filament screen, action surface, or custom markup is introduced. Existing tenant and baseline profile surfaces must continue to rely on their current native presentation.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature removes dead interpretation residue rather than adding another semantic layer. Tests must prove current business truth survives while the residue disappears.
### Functional Requirements
- **FR-234-001**: The system MUST remove deprecated baseline profile status aliases from active runtime language once the cleanup proves no productive dependency remains.
- **FR-234-002**: The system MUST treat the canonical baseline profile status contract as the only active source of draft, active, and archived profile semantics.
- **FR-234-003**: The system MUST remove retired tenant app-status support residue from active badge registration, factory defaults, browser fixtures, seeds, and tests when those paths no longer serve a current runtime contract.
- **FR-234-004**: Existing tenant list and tenant detail behavior MUST remain unchanged for current lifecycle, provider, and RBAC truth after the retired residue is removed.
- **FR-234-005**: Existing baseline profile list, view, edit, and archive behavior MUST remain unchanged for current profile status truth after deprecated aliases are removed.
- **FR-234-006**: Every removed residue item MUST be checked for hidden runtime, UI, filter, cast, policy, or API dependency before deletion.
- **FR-234-007**: If a hidden dependency is found, the dependency MUST be documented and moved to a follow-up cleanup decision rather than preserving the residue as silent compatibility lore.
- **FR-234-008**: The feature MUST NOT introduce compatibility aliases, fallback readers, migration shims, or new legacy fixtures to preserve removed residue.
- **FR-234-009**: The feature MUST NOT introduce a new readiness model, new status family, new cleanup ledger, or any other replacement semantic layer.
- **FR-234-010**: Historical storage or migration remnants MAY remain only as historical artifacts and MUST NOT regain default-visible operator meaning.
- **FR-234-011**: Focused regression coverage MUST prove both tenant-truth continuity and baseline-profile continuity after the cleanup.
- **FR-234-012**: The feature MUST stay bounded to dead transitional residue cleanup and MUST NOT absorb onboarding fallback retirement, provider-connection legacy cleanup, or canonical operation-type convergence.
### Key Entities *(include if feature involves data)*
- **Canonical baseline profile status**: The active status language that already governs baseline profile lifecycle behavior.
- **Deprecated baseline profile status aliases**: Retired alias constants that mirror current profile statuses but no longer represent active repo truth.
- **Tenant app-status residue**: Retired support artifacts around a legacy tenant-level status signal that current tenant surfaces no longer treat as authoritative.
- **Residual support artifacts**: Factories, fixtures, seeds, tests, and badge registrations that can conserve dead semantics even when primary product surfaces no longer use them.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-234-001**: In acceptance review, no targeted tenant surface or supporting default path reintroduces tenant app-status as current truth.
- **SC-234-002**: 100% of targeted tenant-truth regressions pass after the retired tenant app-status residue is removed.
- **SC-234-003**: 100% of targeted baseline profile regressions pass after the deprecated baseline profile status aliases are removed.
- **SC-234-004**: The cleanup ships without adding any new persistence, status family, compatibility shim, or replacement semantic layer.
## Assumptions
- The current baseline profile status contract is already sufficient for existing profile behavior.
- Existing tenant surfaces no longer require app-status to express current tenant truth.
- Historical migration files may retain old field names or values as history without counting as active runtime truth.
## Non-Goals
- Dropping legacy database columns in this slice
- Redesigning tenant readiness, provider readiness, or baseline readiness semantics
- Performing onboarding fallback retirement
- Performing provider-connection legacy cleanup outside the dead tenant app-status residue
- Resolving the canonical operation-type source-of-truth conflict
## Dependencies
- Existing tenant lifecycle, provider, and RBAC truth separation on current tenant surfaces
- Existing baseline profile behavior and current baseline profile status contract
- Existing focused regressions for tenant truth, baseline profile behavior, and central badge registration
## Definition of Done
- Deprecated baseline profile status aliases are gone from active runtime language.
- Retired tenant app-status residue is gone from active badge registration, default fixtures, seeds, and tests unless an explicit still-active dependency is documented.
- Existing tenant and baseline profile behaviors remain unchanged for current truth.
- No new compatibility path or replacement status layer was introduced.
- Focused regression coverage passes.

View File

@ -0,0 +1,225 @@
# Tasks: Dead Transitional Residue Cleanup
**Input**: Design documents from `/specs/234-dead-transitional-residue/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`
**Tests**: Required. This feature changes runtime behavior by removing active runtime/support residue, so Pest coverage must be added or updated in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`.
**Operations**: No new `OperationRun`, audit-only DB action, or queued workflow is introduced. This cleanup stays inside existing runtime behavior, fixture defaults, and regression coverage.
**RBAC**: No authorization semantics change. Existing tenant/admin Filament access, tenant isolation, and current `404` versus `403` behavior must remain unchanged in the touched tenant and baseline regression files.
**UI / Surface Guardrails**: No operator-facing surface is added or redesigned. Keep `standard-native-filament` relief and use the existing tenant and baseline pages only as continuity proof.
**Filament UI Action Surfaces**: No new Filament Resource, Page, RelationManager, or action surface is introduced. `TenantResource` and `BaselineProfileResource` keep their current surfaces and global-search posture.
**Badges**: `BadgeCatalog` remains authoritative. The legacy tenant app-status badge domain must be removed centrally from `apps/platform/app/Support/Badges/BadgeCatalog.php` and `apps/platform/app/Support/Badges/BadgeDomain.php`, and active badge domains must remain covered by tests.
**Organization**: Tasks are grouped by user story so each slice stays independently testable after the shared proof surfaces are prepared. Recommended delivery order is `US1` and `US2` in parallel after Foundational, then a final cross-cutting verification phase, because the retirement proof only matters once the tenant and baseline cleanup slices are both in place.
## Test Governance Checklist
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [X] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [X] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [X] Planned validation commands cover the change without pulling in unrelated lane cost.
- [X] The declared surface test profile or `standard-native-filament` relief is explicit.
- [X] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Shared Cleanup Anchors)
**Purpose**: Lock the cleanup inventory and proving commands before editing runtime or test files.
- [X] T001 [P] Verify the cleanup anchor inventory across `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php`, `apps/platform/database/factories/TenantFactory.php`, and `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`
- [X] T002 [P] Verify the narrow validation lane and proving commands in `specs/234-dead-transitional-residue/plan.md` and `specs/234-dead-transitional-residue/quickstart.md`
**Checkpoint**: Cleanup scope and proving commands are locked before code changes begin.
---
## Phase 2: Foundational (Blocking Proof Surfaces)
**Purpose**: Audit the real consumer boundaries before removing residue so story work does not rediscover hidden dependencies mid-slice.
**CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Audit all `TenantAppStatus` and `TenantAppStatusBadge` consumers across `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`
- [X] T004 [P] Audit every ambient or explicit `app_status` usage boundary across `apps/platform/database/factories/TenantFactory.php`, `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`, `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, and `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- [X] T005 [P] Audit every `BaselineProfile::STATUS_` consumer and baseline continuity proof file across `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`
- [X] T006 [P] Lock the cross-cutting retirement proof and follow-up decision sink in `specs/234-dead-transitional-residue/plan.md`, `specs/234-dead-transitional-residue/quickstart.md`, and `specs/234-dead-transitional-residue/tasks.md`
**Checkpoint**: Hidden-dependency boundaries are explicit and the story slices can proceed without overlapping proof setup work.
---
## Phase 3: User Story 1 - Keep Tenant Truth Free Of Retired App-Status Semantics (Priority: P1) 🎯 MVP
**Goal**: Remove tenant app-status residue from active badge/default paths without changing current tenant lifecycle, provider, or RBAC truth.
**Independent Test**: Run the tenant-truth regressions with explicit legacy `app_status` values and verify the tenant list/detail surfaces still suppress them while active tenant truth remains unchanged.
### Tests for User Story 1
- [X] T007 [P] [US1] Add tenant list/detail assertions that explicit historical `app_status` values stay suppressed in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`
- [X] T008 [P] [US1] Add lifecycle and RBAC separation assertions that do not rely on `TenantFactory` defaults in `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- [X] T009 [P] [US1] Add tenant badge assertions that the legacy app-status domain no longer participates in active tenant semantics in `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`
### Implementation for User Story 1
- [X] T010 [P] [US1] Remove the `TenantAppStatus` registration path from `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/BadgeCatalog.php`
- [X] T011 [US1] Delete the retired mapper in `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php`
- [X] T012 [P] [US1] Remove the ambient `app_status` default from `apps/platform/database/factories/TenantFactory.php`
- [X] T013 [P] [US1] Remove the forced tenant `app_status` fixture value from `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`
- [X] T014 [US1] Reconcile explicit legacy setup and active-domain expectations in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, and `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`
**Checkpoint**: User Story 1 is independently functional and tenant truth no longer depends on the retired badge path or ambient `app_status` defaults.
---
## Phase 4: User Story 2 - Use One Baseline Profile Status Language (Priority: P1)
**Goal**: Remove deprecated baseline profile alias language so `BaselineProfileStatus` is the only active lifecycle contract.
**Independent Test**: Run the existing baseline profile archive, list/filter, view/edit continuity, and workspace-ownership regressions after alias removal and verify behavior is unchanged.
### Tests for User Story 2
- [X] T015 [P] [US2] Add archive-flow assertions that only `BaselineProfileStatus` drives lifecycle behavior in `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`
- [X] T016 [P] [US2] Add list/filter assertions that baseline profile behavior does not require alias constants in `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`
- [X] T017 [P] [US2] Add view/edit continuity assertions after alias removal in `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`
- [X] T018 [P] [US2] Add workspace-ownership continuity assertions after alias removal in `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`
### Implementation for User Story 2
- [X] T019 [US2] Remove deprecated `STATUS_DRAFT`, `STATUS_ACTIVE`, and `STATUS_ARCHIVED` constants from `apps/platform/app/Models/BaselineProfile.php`
- [X] T020 [US2] Update baseline profile regressions to use only `App\Support\Baselines\BaselineProfileStatus` in `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`
**Checkpoint**: User Story 2 is independently functional and baseline profile lifecycle behavior now has one canonical status language.
---
## Phase 5: Cross-Cutting Verification - Prove The Residue Is Fully Retired
**Goal**: Lock in regression proof that the retired semantics are gone from active runtime/support paths.
**Release Gate**: Run the focused regression pack plus the residue searches after User Story 1 and User Story 2 are complete, and confirm there are no unexpected matches or hidden-default dependencies left in the touched files.
### Verification for Cross-Cutting Closeout
- [X] T021 [P] Add badge catalog assertions that the retired tenant app-status domain is absent while active domains remain registered in `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`
- [X] T022 [P] Add regression assertions that legacy `app_status` is always opt-in setup in `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` and `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
### Implementation for Cross-Cutting Closeout
- [X] T023 Run and review the residue searches for `BaselineProfile::STATUS_|TenantAppStatus|tenant_app_status` and `app_status` across `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php`, `apps/platform/database/factories/TenantFactory.php`, `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`, `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`
- [X] T024 Record any hidden-dependency follow-up or confirm clean retirement in `specs/234-dead-transitional-residue/plan.md` and `specs/234-dead-transitional-residue/quickstart.md`
**Checkpoint**: The feature has explicit proof that the dead residue is no longer part of active truth.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finish formatting and run the narrow proving workflow for the full cleanup.
- [X] T025 Run formatting for `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/database/factories/TenantFactory.php`, `apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php`, `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, and `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [X] T026 [P] Run the tenant-truth validation pack from `specs/234-dead-transitional-residue/quickstart.md` against `apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php` and `apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php`
- [X] T027 [P] Run the baseline-profile and badge validation pack from `specs/234-dead-transitional-residue/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php`, `apps/platform/tests/Feature/Baselines/BaselineProfileAuthorizationTest.php`, `apps/platform/tests/Unit/Badges/TenantBadgesTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and locks the cleanup inventory plus proving commands.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the hidden-dependency boundaries and closeout proof sinks are explicit.
- **User Story 1 (Phase 3)**: Depends on Foundational and is the recommended MVP cut.
- **User Story 2 (Phase 4)**: Depends on Foundational and can proceed in parallel with User Story 1 because it touches a separate runtime truth domain.
- **Cross-Cutting Verification (Phase 5)**: Depends on User Story 1 and User Story 2 because the final retirement proof only makes sense after both cleanup slices land.
- **Polish (Phase 6)**: Depends on all desired user stories and the cross-cutting verification phase being complete.
### User Story Dependencies
- **US1**: No dependencies beyond Foundational.
- **US2**: No dependencies beyond Foundational.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation is considered complete.
- Keep the cleanup canonical: no compatibility aliases, no fallback readers, and no restoration of ambient legacy defaults.
- Keep `BadgeCatalog` authoritative for tenant badge semantics and `BaselineProfileStatus` authoritative for baseline lifecycle semantics.
- Finish story-level verification before moving to the next dependent slice.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003`, `T004`, `T005`, and `T006` can run in parallel during Foundational work.
- `T007`, `T008`, and `T009` can run in parallel for User Story 1, followed by `T010`, `T011`, `T012`, and `T013` in parallel before reconciling tests in `T014`.
- `T015`, `T016`, `T017`, and `T018` can run in parallel for User Story 2.
- User Story 1 and User Story 2 can proceed in parallel after Foundational is complete.
- `T021` and `T022` can run in parallel during cross-cutting verification.
- `T026` and `T027` can run in parallel during final validation.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T007 apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php
T008 apps/platform/tests/Feature/Filament/TenantLifecycleStatusDomainSeparationTest.php
T009 apps/platform/tests/Unit/Badges/TenantBadgesTest.php
# User Story 1 implementation in parallel after the tests are in place
T010 apps/platform/app/Support/Badges/BadgeDomain.php + apps/platform/app/Support/Badges/BadgeCatalog.php
T011 apps/platform/app/Support/Badges/Domains/TenantAppStatusBadge.php
T012 apps/platform/database/factories/TenantFactory.php
T013 apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T015 apps/platform/tests/Feature/Baselines/BaselineProfileArchiveActionTest.php
T016 apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php
T017 apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php
T018 apps/platform/tests/Feature/Baselines/BaselineProfileWorkspaceOwnershipTest.php
```
## Parallel Example: Cross-Story Delivery After Foundational
```bash
# Tenant cleanup and baseline cleanup can proceed in parallel after Phase 2
T010-T014 apps/platform/app/Support/Badges/* + apps/platform/database/factories/TenantFactory.php + apps/platform/app/Console/Commands/SeedBackupHealthBrowserFixture.php
T019-T020 apps/platform/app/Models/BaselineProfile.php + apps/platform/tests/Feature/Baselines/* + apps/platform/tests/Feature/Filament/BaselineProfileListFiltersTest.php + apps/platform/tests/Feature/Filament/BaselineProfileScopeV2PersistenceTest.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Run `T025` and `T026` before widening the slice.
### Incremental Delivery
1. Ship User Story 1 to remove tenant app-status residue from active badge/default paths.
2. Ship User Story 2 to collapse baseline lifecycle language onto `BaselineProfileStatus` only.
3. Run the cross-cutting verification phase to lock in proof that the residue is fully retired.
4. Finish with formatting and the focused validation workflow.
### Parallel Team Strategy
1. One contributor can prepare the badge and tenant-truth proof surfaces while another prepares the baseline continuity proof surfaces in Phase 2.
2. After Foundational is complete, one contributor can execute User Story 1 while another executes User Story 2.
3. Once both cleanup slices land, a final pass can focus on cross-cutting retirement proof and the narrow validation commands.
---
## Notes
- `[P]` tasks target different files and can be worked independently once upstream blockers are cleared.
- `[US1]` and `[US2]` map directly to the feature specification user stories.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- All tasks above follow the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Baseline Capture Truthful Outcomes and Upstream Guardrails
**Purpose**: Capture specification completeness and quality at planning handoff, while keeping post-plan status notes aligned with the current artifact set
**Created**: 2026-04-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation algorithms, code diffs, or migration steps
- [x] Focused on user value and business needs
- [x] Repo-specific constitutional and surface-contract references remain intentional and bounded
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation algorithms or file-by-file execution steps leak into specification
## Notes
- This checklist records the spec's readiness at planning handoff; `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, and `tasks.md` now exist as the implementation-facing artifacts for this feature.
- Repo-specific constitutional contract references are intentional and bounded; the spec still avoids implementation algorithms, code diffs, migration steps, and file-by-file implementation plans.
- No clarification markers remain, and the current scope is fully aligned across spec, plan, tasks, and supporting artifacts for implementation.

View File

@ -0,0 +1,164 @@
# Data Model: Baseline Capture Truthful Outcomes and Upstream Guardrails
## Overview
This feature does not add a new persisted entity. It tightens the behavioral contract of three existing truths:
1. `BaselineProfile.active_snapshot_id` defines the current consumable baseline anchor.
2. `BaselineSnapshot.lifecycle_state` plus `completion_meta_jsonb` define whether a captured artifact is consumable.
3. `OperationRun` outcome, summary counts, and context define the operator-visible truth for blocked and no-data capture attempts.
## Entities
### BaselineProfile
**Table / Model**: `baseline_profiles` / `App\Models\BaselineProfile`
**Relevant fields**:
| Field | Type | Purpose in this feature |
|------|------|--------------------------|
| `id` | integer | Baseline identity |
| `workspace_id` | integer | Workspace isolation boundary |
| `status` | enum | Only active profiles can promote a new current snapshot |
| `capture_mode` | enum | Existing capture fidelity setting |
| `scope_jsonb` | jsonb | Determines in-scope policy types / foundations |
| `active_snapshot_id` | nullable integer | Current baseline truth pointer |
**Relationships**:
- `activeSnapshot(): BelongsTo`
- `snapshots(): HasMany`
- `resolveCurrentConsumableSnapshot(): ?BaselineSnapshot`
**Behavioral rule added by this feature**:
- `active_snapshot_id` only changes when the capture attempt yields a consumable snapshot.
- Blocked latest-inventory preconditions and zero-subject/no-data captures must not clear or advance the pointer.
### BaselineSnapshot
**Table / Model**: `baseline_snapshots` / `App\Models\BaselineSnapshot`
**Relevant fields**:
| Field | Type | Purpose in this feature |
|------|------|--------------------------|
| `id` | integer | Snapshot artifact identity |
| `workspace_id` | integer | Workspace isolation boundary |
| `baseline_profile_id` | integer | Owning baseline profile |
| `snapshot_identity_hash` | string | Deduplication / equality proof for captured content |
| `captured_at` | timestamp | Artifact recency |
| `completed_at` | nullable timestamp | Completion boundary |
| `lifecycle_state` | enum | `building`, `complete`, `incomplete` |
| `summary_jsonb` | jsonb | Aggregate capture counts / fidelity / gaps |
| `completion_meta_jsonb` | jsonb | Completion proof and finalization reason details |
**Existing lifecycle reused**:
- `complete`: Consumable snapshot truth.
- `incomplete`: Non-consumable artifact truth.
**Behavioral rule added by this feature**:
- If a zero-subject capture persists a snapshot row, it remains `incomplete` and non-consumable.
- The no-data finalization reason is stored in `completion_meta_jsonb` rather than introducing a new lifecycle state.
- Zero-subject capture must not reuse a historical complete snapshot as if it were the result of the current attempt.
**Expected completion metadata keys**:
| Key | Type | Meaning |
|-----|------|---------|
| `expected_items` | integer | Number of items the job expected to persist |
| `persisted_items` | integer | Number of items actually persisted |
| `producer_run_id` | integer | Owning `baseline_capture` run |
| `was_empty_capture` | boolean | Indicates zero-subject/no-data attempt |
| `finalization_reason_code` | string | Existing or new baseline reason code when incomplete |
### OperationRun
**Table / Model**: `operation_runs` / existing Operations subsystem
**Relevant fields**:
| Field | Type | Purpose in this feature |
|------|------|--------------------------|
| `id` | integer | Operation identity |
| `operation_type` | enum/string | `baseline_capture` |
| `status` | enum/string | `queued`, `running`, `completed` |
| `outcome` | enum/string | `blocked`, `partially_succeeded`, `succeeded`, existing failure outcomes |
| `summary_counts` | json | Flat numeric counts only |
| `context` | json | Detailed capture explanation |
**New or newly-required context keys**:
| Path | Type | Meaning |
|------|------|---------|
| `baseline_capture.reason_code` | string | Dominant blocked or no-data reason |
| `baseline_capture.inventory_sync_run_id` | nullable integer | Latest relevant inventory basis consulted |
| `baseline_capture.subjects_total` | integer | Number of in-scope subjects discovered when subject evaluation runs |
| `baseline_capture.current_baseline_changed` | boolean | Whether the capture attempt changed current consumable truth |
| `baseline_capture.eligibility` | object/array | Optional structured detail about upstream inventory credibility |
| `result.snapshot_id` | nullable integer | Persisted snapshot artifact, if any |
| `result.snapshot_lifecycle` | nullable string | Lifecycle of the persisted or reused snapshot artifact when one is attached to the result |
**Outcome rules introduced or tightened by this feature**:
- `completed + blocked`: The run started, but the latest inventory basis was not credible when execution actually occurred.
- `completed + partially_succeeded`: Zero-subject/no-data capture or existing warning/gap semantics where a run completed without producing a full trustworthy baseline refresh.
- `completed + succeeded`: Reserved for captures that produce or reuse a consumable snapshot truth and leave the effective baseline anchored to that consumable snapshot.
### Upstream Inventory Basis
**Source**: Existing inventory sync `OperationRun` and `InventoryItem` records
**Purpose in this feature**:
- Determine whether a baseline capture may start.
- Determine whether a queued capture is still valid when it executes.
- Determine how many in-scope subjects are currently available for capture.
**Behavioral rule added by this feature**:
- Only the latest relevant inventory sync may authorize capture.
- No earlier successful run may be used as silent fallback when a newer relevant run is blocked, failed, or missing.
## Relationships
| From | To | Relationship | Feature consequence |
|------|----|--------------|---------------------|
| `BaselineProfile` | `BaselineSnapshot` | one-to-many | A profile may have multiple attempted snapshots, but only consumable ones may become current truth |
| `BaselineProfile` | `BaselineSnapshot` | one active snapshot pointer | Pointer remains on last consumable snapshot when new attempt is blocked or no-data |
| `OperationRun` | `BaselineProfile` | contextual | Capture run context references the profile being captured |
| `OperationRun` | `BaselineSnapshot` | contextual | Run context references produced artifact if one exists |
| `InventoryItem` / inventory sync run | `BaselineProfile` capture attempt | derived eligibility | Determines whether capture may produce trustworthy baseline truth |
## State Transitions
### Capture start-time preflight
| Condition | Run created? | Result |
|-----------|--------------|--------|
| Profile inactive / archived / tenant mismatch / scope empty | no | Existing precondition rejection |
| Latest relevant inventory basis missing / blocked / failed / unusable | no | Shared baseline-capture reason code returned to start surface |
| Latest relevant inventory basis credible | yes | `baseline_capture` run enqueued |
### Queued runtime execution
| Condition | Run terminal state | Snapshot effect | Current baseline effect |
|-----------|--------------------|-----------------|-------------------------|
| Latest relevant inventory becomes non-credible after enqueue | `completed + blocked` | none | unchanged |
| Credible inventory but `subjects_total = 0` | `completed + partially_succeeded` | optional non-consumable no-data artifact | unchanged |
| Credible inventory and consumable capture produced or reused | `completed + succeeded` or existing warning-driven `partially_succeeded` | consumable snapshot | remains anchored to the consumable current snapshot |
| Persist / completion proof failure | existing failure / incomplete semantics | incomplete snapshot | unchanged |
## Invariants
- A non-consumable snapshot must never become current baseline truth automatically.
- A green baseline-capture outcome must imply that a consumable snapshot truth exists after the run.
- `summary_counts` stay flat and numeric-only even when blocked/no-data truth is carried by context and reason code.
- Compare-readiness surfaces derive from consumable baseline truth, not merely from the existence of a latest run.
## Conditional Legacy Edge
Existing legacy backfill logic can classify historical empty captures as `complete`. This feature does not change historical rows by default, but if implementation proves that those historical rows still participate in current runtime truth, the legacy classification rule must be adjusted inside this feature and re-proved with `BaselineSnapshotBackfillTest`.

View File

@ -0,0 +1,267 @@
# Implementation Plan: Baseline Capture Truthful Outcomes and Upstream Guardrails
**Branch**: `235-baseline-capture-truth` | **Date**: 2026-04-23 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/235-baseline-capture-truth/spec.md`
**Note**: This plan keeps the slice intentionally narrow. It reuses the existing `BaselineSnapshot` lifecycle/usability model and the existing Ops UX explanation path, then hardens only baseline-capture eligibility, outcome mapping, no-data artifact handling, and current-baseline promotion.
## Summary
Harden baseline capture so it only succeeds when there is a credible inventory basis and at least one in-scope subject produces a consumable snapshot. The implementation will extend the existing capture reason-code family, make `BaselineCaptureService` evaluate the latest relevant inventory sync before enqueue, re-check the same prerequisite inside `CaptureBaselineSnapshotJob`, map zero-subject captures to `partially_succeeded` plus no-data artifact truth, keep `BaselineProfile.active_snapshot_id` anchored to the last consumable snapshot, and route operator messaging through the existing `ReasonTranslator`, `BaselineCompareStats`, and `GovernanceRunDiagnosticSummaryBuilder` paths instead of adding page-local copy branches.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
**Storage**: Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice
**Testing**: Pest v4 feature tests through Laravel Sail
**Validation Lanes**: `fast-feedback`, `confidence`
**Target Platform**: Laravel admin web application in Sail containers with workspace-admin routes under `/admin` and tenant routes under `/admin/t/{tenant}`
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
**Performance Goals**: Preserve current capture request and queued-job behavior; add at most one focused latest-inventory eligibility lookup per capture attempt and no new high-cardinality UI rendering path
**Constraints**: No stale successful inventory fallback, no new persisted entity or lifecycle state, no new generic artifact-truth framework, no auth-plane expansion, and no drift of message semantics into page-local copy
**Scale/Scope**: One existing queued workflow (`baseline_capture`), one reason-code family extension, two existing start surfaces, one snapshot detail surface, one Monitoring run-detail explanation path, and focused baseline/Monitoring test families
## Filament v5 Implementation Contract
- **Livewire v4.0+ compliance**: Preserved. The plan changes existing Filament actions and shared presenters only; it introduces no legacy Livewire patterns.
- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`.
- **Global search coverage**: `BaselineProfileResource` and `BaselineSnapshotResource` both keep global search disabled via `$isGloballySearchable = false`, so this slice adds no global-search exposure and no new view/edit requirement.
- **Destructive actions**: No destructive action is added or changed. The existing `Archive baseline profile` action already uses `->requiresConfirmation()` and remains on its current path.
- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when future work introduces registered assets.
- **Testing plan**: Prove the slice with focused Pest feature coverage for baseline capture service/start surfaces, retained consumable happy-path success, compare landing readiness, snapshot-detail no-data truth, Monitoring run summaries, and the existing audit/terminal-notification contract for `baseline_capture`.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native
- **Shared-family relevance**: status messaging, header actions, run-detail explanations, audit-aligned summaries
- **State layers in scope**: page, detail
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory
- **Special surface test profiles**: `standard-native-filament`, `monitoring-state-page`
- **Required tests or manual smoke**: `functional-core`, `state-contract`
- **Exception path and spread control**: none planned; any unavoidable message deviation must stay bounded to the existing baseline shared presenter/translator path
- **Active feature PR close-out entry**: `Guardrail`
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: baseline capture start surfaces, compare availability/readiness surfaces, baseline snapshot truth presentation, Monitoring run detail, audit prose, canonical reason translation
- **Shared abstractions reused**: `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `OperationRunService`, `OperationUxPresenter`, `GovernanceRunDiagnosticSummaryBuilder`, `OperatorExplanationBuilder`
- **New abstraction introduced? why?**: none planned. If inventory-eligibility logic needs reuse across start-time and runtime recheck, keep it as a narrow `BaselineCaptureService`-owned method or tiny baseline-local helper rather than a new registry/resolver layer.
- **Why the existing abstraction was sufficient or insufficient**: Existing abstractions are sufficient for translation, explanation, and compare-readiness messaging. The current gap is that capture eligibility and no-data truth do not yet feed those shared paths consistently.
- **Bounded deviation / spread control**: none
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with no new persistence, no new UI framework, and no auth-plane drift.*
| Gate | Status | Plan Notes |
|------|--------|------------|
| Inventory-first / read-write separation | PASS | The slice makes capture depend on the latest credible inventory truth and does not introduce any new Graph write or preview path. |
| RBAC, workspace isolation, tenant isolation | PASS | No new routes or capabilities are introduced; existing `/admin`, `/admin/t/{tenant}`, and canonical Monitoring entitlement rules remain authoritative. |
| Run observability / Ops-UX lifecycle | PASS | Existing `baseline_capture` `OperationRun` remains the queued-work truth. Known start-surface preconditions may still short-circuit with no run, while queued runtime rechecks will resolve through `OperationRunService` only. |
| Shared pattern first | PASS | The plan extends existing reason translation and run-summary builders instead of adding page-local message trees. |
| Proportionality / no premature abstraction | PASS | No new persistence or subsystem is planned. The only structural addition is a bounded extension of existing capture reason codes plus reuse of current services/presenters. |
| Persisted truth / behavioral state | PASS | No new table or snapshot lifecycle state is added. No-data capture uses existing snapshot lifecycle/usability semantics if an artifact row is kept. |
| Badge semantics / Filament-native discipline | PASS | Existing badge/outcome semantics remain centralized; touched surfaces stay on native Filament actions and shared presenters. |
| Filament v5 / Livewire v4 contract | PASS | Provider registration, global-search posture, and destructive-action discipline remain unchanged and compliant. |
| Test governance | PASS | Proof stays in focused baseline and Monitoring feature lanes without heavy-governance or browser expansion. |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Feature` for service/start-surface, compare-readiness, retained consumable success, snapshot-detail truth, and Monitoring truth
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The business truth lives in existing capture execution, existing Filament surfaces, and existing Monitoring detail. Focused feature tests prove the slice end-to-end without widening into browser or heavy-governance families.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Notifications/OperationRunNotificationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineSnapshotBackfillTest.php` only if legacy empty-snapshot classification changes prove necessary during implementation
- **Fixture / helper / factory / seed / context cost risks**: Moderate. The slice needs explicit inventory-run outcome fixtures (`no inventory`, `blocked`, `failed`, `unusable coverage`, `after-enqueue drift to non-credible`, `credible but zero subjects`, `previous complete snapshot still current`) and must keep those scenarios opt-in rather than adding new default helpers.
- **Expensive defaults or shared helper growth introduced?**: No. New inventory eligibility scenarios should stay local to baseline capture tests.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `standard-native relief` for profile/compare surfaces, `monitoring-state-page` for run-detail explanation assertions
- **Closing validation and reviewer handoff**: Reviewers should verify that no test still encodes empty capture as unconditional success, that unusable coverage and after-enqueue prerequisite drift are proved explicitly, that `active_snapshot_id` never advances on blocked/zero-subject capture paths, that compare landing still derives readiness from consumable baseline truth, that snapshot detail distinguishes no-data evidence from current baseline truth, and that Monitoring, audit prose, and terminal notification copy lead with the same dominant cause before diagnostics.
- **Budget / baseline / trend follow-up**: none expected beyond a small increase in baseline and Monitoring feature assertions
- **Review-stop questions**: Did any new helper start hiding expensive inventory/run setup? Did the plan accidentally widen into compare-engine or generic artifact-state work? Did any runtime branch bypass `OperationRunService`? Did any surface add local copy that duplicates the shared reason/summary path?
- **Escalation path**: `document-in-feature` unless legacy empty-snapshot backfill proves structurally necessary, in which case reassess inside this feature before widening further
- **Active feature PR close-out entry**: `Guardrail`
- **Why no dedicated follow-up spec is needed**: The slice is a bounded hardening change on one existing workflow and one existing operator truth family. Only a forced legacy-row reclassification problem would justify widening further.
## Project Structure
### Documentation (this feature)
```text
specs/235-baseline-capture-truth/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── checklists/
│ └── requirements.md
└── tasks.md
```
No contracts artifact is planned because this feature changes no external API, route contract, or standalone logical interaction contract.
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ └── BaselineCompareLanding.php
│ │ └── Resources/
│ │ ├── BaselineSnapshotResource/
│ │ │ └── Pages/
│ │ │ └── ViewBaselineSnapshot.php
│ │ └── BaselineProfileResource/
│ │ └── Pages/
│ │ └── ViewBaselineProfile.php
│ ├── Jobs/
│ │ └── CaptureBaselineSnapshotJob.php
│ ├── Models/
│ │ ├── BaselineProfile.php
│ │ └── BaselineSnapshot.php
│ ├── Notifications/
│ │ └── OperationRunCompleted.php
│ ├── Services/
│ │ ├── OperationRunService.php
│ │ └── Baselines/
│ │ └── BaselineCaptureService.php
│ └── Support/
│ ├── Baselines/
│ │ ├── BaselineCompareStats.php
│ │ └── BaselineReasonCodes.php
│ ├── OpsUx/
│ │ ├── GovernanceRunDiagnosticSummaryBuilder.php
│ │ └── OperationUxPresenter.php
│ ├── ReasonTranslation/
│ │ └── ReasonTranslator.php
│ └── Ui/
│ └── OperatorExplanation/
│ └── OperatorExplanationBuilder.php
└── tests/
├── Feature/
│ ├── Authorization/
│ │ └── OperatorExplanationSurfaceAuthorizationTest.php
│ ├── Baselines/
│ │ ├── BaselineCaptureTest.php
│ │ └── BaselineSnapshotBackfillTest.php
│ ├── Filament/
│ │ ├── BaselineCaptureResultExplanationSurfaceTest.php
│ │ ├── BaselineCompareLandingStartSurfaceTest.php
│ │ ├── BaselineProfileCaptureStartSurfaceTest.php
│ │ └── OperationRunBaselineTruthSurfaceTest.php
│ ├── Notifications/
│ │ └── OperationRunNotificationTest.php
│ └── Monitoring/
│ ├── AuditCoverageGovernanceTest.php
│ └── GovernanceOperationRunSummariesTest.php
```
**Structure Decision**: Keep the work entirely inside the existing Laravel runtime in `apps/platform`. The slice changes one existing queued workflow, two existing Filament start surfaces, one immutable snapshot detail surface, shared compare-readiness and explanation helpers, the existing audit/notification composition path, and focused regression families. No new module or subsystem is introduced.
## Complexity Tracking
No constitutional violation is planned. No complexity exception is currently required.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Proportionality Review
- **Current operator problem**: Baseline capture can report success even when no trustworthy baseline exists, which directly misleads operators and auditors.
- **Existing structure is insufficient because**: `BaselineCaptureService` currently validates only profile/tenant/scope preconditions, and `CaptureBaselineSnapshotJob` promotes `active_snapshot_id` whenever a consumable snapshot exists or can be reused, including all-zero paths that are not decision-grade.
- **Narrowest correct implementation**: Extend the existing capture reason-code family, reuse the existing snapshot lifecycle/usability model, add one shared inventory-eligibility evaluation path for start-time and runtime recheck, and adapt existing translator/stats/run-summary surfaces.
- **Ownership cost created**: A few new reason-code translations, one extra eligibility branch in capture service/job, a small amount of extra run-context metadata, and focused regression fixtures for inventory-run truth.
- **Alternative intentionally rejected**: A generic artifact-no-data framework or stale-inventory fallback. The first imports too much structure; the second would preserve false reassurance.
- **Release truth**: current-release truth
## Phase 0 Research Summary
- `BaselineCaptureService` is the current start-time gate and can reject capture without creating an `OperationRun`; it is the right place for latest-inventory eligibility preflight.
- `CaptureBaselineSnapshotJob` currently updates `active_snapshot_id` whenever the resulting snapshot is consumable and currently treats `expected_items === 0` as a valid complete capture. That is the concrete root of the false-green/no-data promotion problem.
- `BaselineReasonCodes`, `ReasonTranslator`, `BaselineCompareStats`, and `GovernanceRunDiagnosticSummaryBuilder` already centralize the operator language for baseline truth and Monitoring explanations; they are the right shared paths to extend.
- `BaselineProfile::resolveCurrentConsumableSnapshot()` already falls back to the latest complete snapshot when `active_snapshot_id` is unusable, so preserving the previous trustworthy baseline is already supported if the capture path stops advancing `active_snapshot_id` incorrectly.
- `OperationRunOutcome::PartiallySucceeded` already exists and is already rendered consistently across Ops UX, badges, and Monitoring; no new run-outcome family is needed.
- Legacy empty-snapshot backfill currently classifies proven empty captures as `complete`. The mainline plan does not widen into migration/backfill unless implementation proves that historical empty snapshots still act as current truth in active runtime paths.
## Phase 1 Design Summary
- `research.md` records the product and architectural decisions: strict latest-inventory truth, no stale fallback, no new snapshot state, and reuse of shared reason/summary infrastructure.
- `data-model.md` documents the touched existing truths: `BaselineProfile.active_snapshot_id`, `BaselineSnapshot.lifecycle_state` plus completion metadata, and `OperationRun.context` keys for inventory eligibility and current-baseline-change effect.
- `quickstart.md` gives the narrow validation order for service preflight, queued runtime recheck, no-data capture, compare-readiness truth, snapshot-detail truth, Monitoring explanation, and audit/notification alignment.
- No contracts artifact is planned because this slice changes no external API or logical interaction contract.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Implementation Strategy
### Phase A — Extend capture eligibility around the latest credible inventory run
**Goal**: Make capture start and queued execution agree on whether the latest relevant inventory basis is trustworthy enough to build a baseline.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Support/Baselines/BaselineReasonCodes.php` | Add the bounded capture reason codes for missing latest inventory, blocked latest inventory, failed latest inventory, unusable coverage, and zero-subject outcome. Keep them in the existing reason-code family. |
| A.2 | `apps/platform/app/Services/Baselines/BaselineCaptureService.php` | Extend `validatePreconditions()` with a reusable latest-inventory eligibility decision that inspects the most recent relevant inventory sync and returns the new capture reason codes without creating an `OperationRun` when the block is already known at start time. |
| A.3 | `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php` | Add operator-safe translations and next steps for the new baseline-capture reason codes so profile/start-surface, Monitoring, and audit-aligned prose stay consistent. |
| A.4 | `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` | Re-check the same eligibility after the run starts, so prerequisite drift between page load and execution resolves through `OperationRunService` with `completed + blocked` rather than a false green run. |
### Phase B — Stop no-data captures from becoming current baseline truth
**Goal**: Treat zero-subject capture as real audit evidence with follow-up, not as a trustworthy baseline refresh, and keep compare readiness anchored to the same consumable-truth contract.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` | Split the zero-subject path from the normal consumable-snapshot path before any existing consumable snapshot is reused or `active_snapshot_id` is advanced. |
| B.2 | `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` | Map zero-subject capture to `OperationRunOutcome::PartiallySucceeded`, record the new reason code in run context, keep numeric `summary_counts`, record `baseline_capture.subjects_total`, record `result.snapshot_lifecycle` when an artifact exists, and record whether current baseline truth changed. |
| B.3 | `apps/platform/app/Models/BaselineSnapshot.php` and job call sites | Reuse the existing lifecycle/usability model if a no-data artifact row is retained: mark it non-consumable via existing incomplete semantics and store the finalization reason in `completion_meta_jsonb` rather than introducing a new snapshot state. |
| B.4 | `apps/platform/app/Models/BaselineProfile.php` and job promotion path | Preserve the previously consumable snapshot by ensuring `active_snapshot_id` is updated only when the new capture result is actually consumable. |
| B.5 | `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | Distinguish current trustworthy baseline truth from no-data evidence on snapshot and profile detail surfaces so operators do not read a zero-subject artifact as a normal refresh. |
| B.6 | `apps/platform/app/Support/Baselines/BaselineCompareStats.php` | Extend compare-readiness and missing-snapshot guidance so compare landing and profile-level compare affordances can explain why compare is unavailable after a blocked, failed, or zero-subject capture without inferring success from snapshot existence or the latest run alone. |
| B.7 | `apps/platform/app/Filament/Pages/BaselineCompareLanding.php` | Keep compare availability derived from consumable baseline truth and show the updated explanation-first guidance when the latest capture failed, drifted to a non-credible prerequisite, or produced no usable baseline. |
### Phase C — Align Monitoring explanation and shared audit/notification copy with the hardened capture truth
**Goal**: Make Monitoring and the existing completion summary path speak the same truthful baseline-capture language as the hardened capture and compare-readiness surfaces.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` | Teach baseline-capture summaries to distinguish blocked latest-inventory prerequisites, after-enqueue prerequisite drift, and zero-subject no-data captures from normal success before diagnostics are shown. |
| C.2 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php`, and `apps/platform/app/Notifications/OperationRunCompleted.php` | Keep Monitoring, audit prose, and terminal notification copy aligned to the same dominant baseline-capture reason, whether current baseline truth changed, and initiator-aware notification rules. |
### Phase D — Audit, test, and edge-condition follow-through
**Goal**: Lock the hardened truth into the existing regression families and keep historical edge cases explicit.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php` | Replace the implicit “empty capture succeeds” assumption with explicit coverage for no inventory, blocked inventory, failed inventory, unusable coverage, after-enqueue prerequisite drift, zero subjects, and previous snapshot preservation. |
| D.2 | `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php` | Prove capture preflight messaging, compare readiness, snapshot-detail no-data truth, and no-data explanation on the affected Filament surfaces. |
| D.3 | `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php`, and `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php` | Prove Monitoring detail separates blocked/no-data capture truth from raw counts and generic success wording, and that audit summary plus terminal notification copy preserve the same dominant reason with initiator-aware delivery rules. |
| D.4 | `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` | Keep the authorized happy-path surface access proof explicit and preserve 404 vs 403 semantics on the touched explanation-first surfaces. |
| D.5 | `apps/platform/tests/Feature/Baselines/BaselineSnapshotBackfillTest.php` | Only if implementation proves legacy empty snapshots still participate in active runtime truth, adjust the legacy classification rule and regression accordingly inside this feature instead of adding a second follow-up spec. |
## Risks and Mitigations
- **Local copy drift on capture surfaces**: Existing Filament actions currently branch on reason code locally. Mitigation: converge on `ReasonTranslator` instead of adding more local message cases.
- **Zero-subject path still reuses a historical empty complete snapshot**: Current job flow can reuse an existing consumable snapshot before creating a new one. Mitigation: short-circuit zero-subject handling before `findExistingConsumableSnapshot()` or any `active_snapshot_id` promotion logic can make it authoritative.
- **Queued runtime recheck bypasses Ops-UX rules**: It is easy to update context only and forget terminal run outcome. Mitigation: all blocked/partial terminal states remain service-owned through `OperationRunService` and keep numeric summary counts.
- **Legacy empty backfill broadens the slice unexpectedly**: Historical classification may need adjustment if runtime truth still depends on it. Mitigation: treat it as a conditional step inside this feature, only if a focused regression proves it is necessary.
## Post-Design Re-check
The package remains constitution-compliant, Livewire v4 / Filament v5 compliant, and narrow. It introduces no new persistence, no new UI framework, no new auth plane, and no new operation type. It reuses the existing baseline snapshot lifecycle/usability truth and the existing shared reason/Monitoring explanation paths, and the generated implementation artifacts are aligned for execution.

View File

@ -0,0 +1,164 @@
# Quickstart: Baseline Capture Truthful Outcomes and Upstream Guardrails
## Purpose
Validate that baseline capture no longer reports green success when the upstream inventory basis is not credible or when the capture finds zero in-scope subjects, confirm that the previous consumable baseline remains the effective compare anchor until a new consumable snapshot exists, and verify that Monitoring, audit prose, and terminal notification copy stay aligned to the same dominant truth.
## Preconditions
1. Sail services are running.
2. The workspace has a tenant, baseline profile, and inventory fixtures available for the targeted tests.
3. Baseline resources remain Filament v5 / Livewire v4 surfaces; no extra asset build is expected beyond standard PHP/test tooling.
## Validation Flow
### 1. Start-surface preflight blocks non-credible inventory truth
**Goal**: Known upstream problems are rejected before enqueue and use the shared reason-code family.
Check:
- No latest relevant inventory sync exists.
- Latest relevant inventory sync is blocked or failed.
- Latest relevant inventory sync exists but does not provide usable in-scope coverage.
Expected result:
- Capture does not enqueue a run.
- The start surface shows the shared translated baseline-capture reason.
- No success wording appears.
## 2. Runtime recheck blocks prerequisite drift after enqueue
**Goal**: If the latest relevant inventory state changes after page load or after enqueue, the queued run still resolves truthfully.
Check:
- Enqueue capture with a credible latest inventory basis.
- Change the latest relevant inventory run to a blocked/failed/unusable-coverage/non-credible state before the job evaluates subjects.
Expected result:
- The run ends as `completed + blocked`.
- Monitoring and run-detail explanation lead with the upstream inventory reason.
- No snapshot becomes the current consumable baseline.
## 3. Consumable capture still produces succeeded baseline truth
**Goal**: A clean capture with at least one resolved in-scope subject still succeeds and advances effective baseline truth.
Check:
- Use a credible latest inventory run whose effective in-scope subject count is greater than zero.
- Ensure the capture completes without warning conditions that would intentionally downgrade the run to `partially_succeeded`.
Expected result:
- The run ends as `completed + succeeded`.
- A consumable snapshot is produced or reused consistently with the current truth contract.
- `BaselineProfile.active_snapshot_id` remains anchored to the consumable current snapshot after the run, whether that required a new pointer update or reuse of the already-current consumable snapshot.
- Run context and audit summary preserve the same metadata contract for success, including the eligibility decision, upstream inventory reference, and whether current baseline truth changed.
## 4. Zero-subject capture produces no-data truth, not a green refresh
**Goal**: A capture with zero in-scope subjects remains visible but cannot silently refresh current baseline truth.
Check:
- Use a credible latest inventory run whose effective in-scope subject count is zero.
Expected result:
- The run ends as `completed + partially_succeeded`.
- The dominant reason is the zero-subject/no-data capture code.
- Any retained snapshot artifact is non-consumable.
- `BaselineProfile.active_snapshot_id` remains on the previous consumable snapshot.
## 5. Compare readiness stays anchored to consumable baseline truth
**Goal**: Compare surfaces must reflect whether a usable baseline actually exists, not whether a capture was merely attempted.
Check:
- Trigger a blocked capture after a previously successful baseline exists.
- Trigger a zero-subject capture after a previously successful baseline exists.
- Trigger a blocked or zero-subject capture when no previous consumable baseline exists.
Expected result:
- With a previous consumable baseline, compare remains available against that prior truth and explains that the latest capture did not refresh baseline truth.
- The profile-level compare affordance reflects the same truthful availability state and guidance as compare landing.
- Without any consumable baseline, compare remains unavailable and explains why.
## 6. Monitoring summary stays explanation-first
**Goal**: Operators should immediately see whether the run was blocked upstream or completed with no usable baseline.
Check:
- Open the run detail for a blocked latest-inventory capture.
- Open the run detail for a failed latest-inventory capture.
- Open the run detail for a zero-subject capture.
Expected result:
- The summary headline differentiates blocked upstream prerequisites, failed latest inventory, and no-data capture.
- Raw numeric counts remain secondary diagnostics.
## 7. Audit prose and terminal notification stay aligned with run truth
**Goal**: Interactive runs, initiator-null runs, and audit coverage must preserve the same dominant baseline-capture reason without introducing notification drift.
Check:
- Complete an interactive blocked baseline-capture run and inspect the terminal `OperationRunCompleted` payload.
- Complete an interactive zero-subject baseline-capture run and inspect the terminal `OperationRunCompleted` payload.
- Complete an initiator-null or scheduled baseline-capture run with blocked or no-data truth.
- Inspect the governance audit coverage surface or assertions for the same runs.
Expected result:
- Interactive terminal notifications use the same dominant blocked or no-data reason vocabulary as Monitoring.
- Initiator-null runs emit no terminal DB notification while preserving Monitoring and audit truth.
- Audit prose records the same dominant cause and whether current baseline truth changed.
## Commands
Run the narrowest proof set from repository root:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php
```
Run the legacy edge check only if implementation touches historical empty-snapshot classification:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineSnapshotBackfillTest.php
```
## Manual Smoke Focus
If a manual UI check is needed after automated proof:
1. Open a baseline profile detail page and verify the capture header action shows the translated upstream block when inventory truth is not credible.
2. Open compare landing after a zero-subject capture and verify it explains whether the prior consumable baseline still anchors compare availability.
3. Open the Monitoring run detail and verify the headline distinguishes upstream block from no-data capture before showing counts.
4. Verify an interactive run shows aligned terminal notification wording, while an initiator-null run leaves notification delivery suppressed and keeps Monitoring as the audit surface.
## Close-out Record
Record the feature close-out outcome here and mirror it into the active PR description:
1. `Guardrail` status for the changed native Filament surfaces.
2. Whether `standard-native-filament` and `monitoring-state-page` coverage both ran successfully.
3. Whether `T022` stayed `document-in-feature` or required a follow-up for legacy empty-snapshot behavior.
### Recorded Outcome
- `Guardrail`: pass
- `standard-native-filament`: pass
- `monitoring-state-page`: pass
- `T022`: implemented in feature; legacy empty complete snapshots are now backfilled as incomplete no-data captures with `baseline.capture.zero_subjects`

View File

@ -0,0 +1,55 @@
# Research: Baseline Capture Truthful Outcomes and Upstream Guardrails
## Decision 1: The latest relevant inventory sync is the only authoritative upstream basis for baseline capture
- **Decision**: Evaluate baseline-capture eligibility against the latest relevant inventory sync outcome and coverage. Do not fall back to an older successful run when the latest relevant run is missing, blocked, failed, or otherwise not credible.
- **Rationale**: The operator problem is false reassurance. Any stale-success fallback would preserve the exact failure mode this feature is meant to remove.
- **Alternatives considered**:
- Use the most recent successful inventory sync even if a newer one failed. Rejected because it hides current upstream truth.
- Allow capture to proceed and only warn later. Rejected because a green run can still be interpreted as a trustworthy baseline refresh.
## Decision 2: Reuse the existing baseline reason-code family and shared translator path
- **Decision**: Add the new blocked/no-data reasons to `BaselineReasonCodes` and translate them through `ReasonTranslator` instead of introducing surface-local message trees.
- **Rationale**: The same reasons must be understood on start surfaces, compare-readiness surfaces, Monitoring summaries, and audit-aligned explanations. A single translator path keeps those surfaces coherent.
- **Alternatives considered**:
- Keep separate copy in each Filament page. Rejected because wording and operator guidance would drift immediately.
- Create a brand-new presenter family for baseline capture. Rejected because the existing translator and Ops UX summary builders already cover this responsibility.
## Decision 3: Zero-subject capture is a truthful no-data outcome, not a successful baseline refresh
- **Decision**: When capture finds zero in-scope subjects, complete the run as `partially_succeeded`, record a dedicated no-data reason code, and ensure any persisted snapshot artifact remains non-consumable by reusing existing incomplete lifecycle semantics.
- **Rationale**: Zero captured subjects is real evidence, but it does not produce a trustworthy baseline for compare and must not be represented as a successful refresh.
- **Alternatives considered**:
- Keep zero-subject capture as `succeeded`. Rejected because it produces a false-green operator signal.
- Add a new `no_data` snapshot lifecycle state. Rejected because existing incomplete/non-consumable semantics already express the required truth without widening the state model.
## Decision 4: Current baseline truth remains anchored to the last consumable snapshot
- **Decision**: Only advance `BaselineProfile.active_snapshot_id` when the new capture result is consumable. Blocked captures and zero-subject captures leave the previous consumable snapshot in place.
- **Rationale**: The product already has `BaselineProfile::resolveCurrentConsumableSnapshot()`. The narrowest correct hardening is to stop promoting non-truthful results rather than inventing a second pointer or state family.
- **Alternatives considered**:
- Store a second pointer for “latest attempted snapshot.” Rejected because the existing latest-attempt and run-detail paths already provide diagnostics.
- Clear `active_snapshot_id` on blocked or zero-subject capture. Rejected because it would discard previously trustworthy truth and make compare availability noisier than necessary.
## Decision 5: Monitoring and compare-readiness surfaces extend the existing shared explanation builders
- **Decision**: Drive dominant explanation text through `BaselineCompareStats` and `GovernanceRunDiagnosticSummaryBuilder`, with `ReasonTranslator` supplying the operator-safe wording.
- **Rationale**: These shared helpers already own the explanation-first contract for baseline truth and Monitoring. Extending them keeps the feature inside existing UX boundaries.
- **Alternatives considered**:
- Add special-case summaries directly in `ViewBaselineProfile` and `BaselineCompareLanding`. Rejected because those pages are consumers, not the source of truth.
- Push explanation logic into ad-hoc JSON context parsing per test/page. Rejected because it spreads behavior and makes regressions harder to prove.
## Decision 6: Legacy empty-snapshot backfill changes stay conditional
- **Decision**: Do not widen the feature into a migration or blanket backfill reclassification unless focused implementation evidence shows historical empty complete snapshots still participate in current runtime truth.
- **Rationale**: The spec explicitly scopes compatibility work out unless it is required. The current-release problem is the live capture path, not historical rows in the abstract.
- **Alternatives considered**:
- Immediately rewrite all historical empty snapshots. Rejected because it widens the feature without proof that runtime truth currently depends on it.
- Ignore the possibility completely. Rejected because the existing legacy backfill test demonstrates a concrete edge that may need adjustment if runtime truth reaches it.
## Resolved Clarifications
- No new table, external API contract, or operation type is required.
- `OperationRunOutcome::PartiallySucceeded` already exists and is the correct no-data outcome family.
- Filament global search remains out of scope because the relevant resources already disable it.

View File

@ -0,0 +1,264 @@
# Feature Specification: Baseline Capture Truthful Outcomes and Upstream Guardrails
**Feature Branch**: `235-baseline-capture-truth`
**Created**: 2026-04-23
**Status**: Draft
**Input**: User description: "Baseline Capture Truthful Outcomes and Upstream Guardrails"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Baseline capture can still present a green success path when no credible baseline was actually produced because the upstream inventory basis was unusable or because zero in-scope subjects resolved.
- **Today's failure**: Operators can read a completed baseline capture run, an all-zero summary, or an empty/reused artifact as if the baseline was successfully refreshed even when there is no trustworthy baseline to compare against.
- **User-visible improvement**: Capture start surfaces warn early, run detail states the real cause and next action first, and a failed or no-data capture no longer silently replaces the last trustworthy baseline.
- **Smallest enterprise-capable version**: Reuse the existing snapshot lifecycle/usability rules from Spec 159 and the existing governance run-summary path from Spec 220, then harden only inventory eligibility, capture outcome mapping, reason codes, and no-data artifact promotion rules for baseline capture.
- **Explicit non-goals**: No redesign of the whole `OperationRun` platform, no broad rewrite of inventory coverage semantics, no compare-engine redesign, no generic no-data framework for all operation types, no new artifact-lifecycle taxonomy, and no silent stale-inventory fallback.
- **Permanent complexity imported**: A bounded extension of existing `BaselineReasonCodes`, translation/presenter mappings for baseline capture truth, a few targeted start-surface and run-detail states, and focused regression coverage.
- **Why now**: This is a near-term governance hardening item and a direct trust gap in one of TenantPilot's core promises: a captured baseline must be meaningful and safe to reason about.
- **Why not local**: The failure crosses capture execution, capture preflight, snapshot promotion, compare availability, Monitoring run detail, and audit/notification translation. A local copy fix would leave the same false-green semantics active elsewhere.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Cross-cutting status messaging; queued-work truth semantics; current-release operator trust on a core governance artifact. Defense: the slice stays narrow by reusing existing baseline snapshot and Ops UX primitives rather than inventing a new generic framework.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: `/admin/baseline-profiles/{record}`, `/admin/baseline-snapshots/{record}`, `/admin/t/{tenant}/baseline-compare`, `/admin/operations/{run}`
- **Data Ownership**: workspace-owned `BaselineProfile` and `BaselineSnapshot` truth, tenant-scoped baseline capture and inventory `OperationRun` context, workspace audit entries
- **RBAC**: Existing workspace membership plus current baseline visibility/capture capability on `/admin`, tenant entitlement plus current compare capability on `/admin/t/{tenant}/...`, and existing Monitoring visibility plus tenant entitlement for tenant-bound run detail
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: status messaging, header actions, run-detail explanations, audit prose, reason translation, terminal notification copy
- **Systems touched**: baseline capture start surfaces, baseline compare availability surfaces, snapshot-truth presentation, Monitoring run detail, audit summary text, canonical reason translation
- **Existing pattern(s) to extend**: existing `BaselineCompareStats` preflight reason-code path, existing snapshot lifecycle/usability contract from Spec 159, and the governance run-summary-first path from Spec 220
- **Shared contract / presenter / builder / renderer to reuse**: `App\Support\Baselines\BaselineReasonCodes`, `App\Support\Baselines\BaselineCompareStats`, `App\Services\OperationRunService`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder`, `App\Support\Ui\OperatorExplanation\OperatorExplanationBuilder`, and the existing `ReasonTranslator`
- **Why the existing shared path is sufficient or insufficient**: The existing shared path already handles compare unavailability, centralized reason translation, and summary-first governance run explanations. The gap is baseline capture truthfulness, not the lack of a shared presentation path.
- **Allowed deviation and why**: none
- **Consistency impact**: The same reason code and operator message must mean the same thing on the profile view, compare landing, snapshot view, Monitoring run detail, audit summary, and any terminal notification derived from the run.
- **Review focus**: Verify that no page-local copy branch or ad-hoc status mapping appears outside the shared baseline reason/summary/explanation path.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Baseline profile view capture truth and header actions | yes | Native Filament + shared baseline/Ops UX primitives | header actions, status messaging | page, header action, related detail | no | n/a |
| Baseline compare landing availability and guidance | yes | Native Filament + shared baseline stats | header actions, status messaging, navigation handoff | page, action, explanation | no | n/a |
| Baseline snapshot detail no-data artifact messaging | yes | Native Filament + shared truth presenters | status messaging, related navigation | detail, artifact truth | no | n/a |
| Monitoring run detail for baseline capture | yes | Native Filament + shared Ops UX presenters | status messaging, run summaries, audit-aligned explanation | detail, diagnostics | no | n/a |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Baseline profile view capture truth and header actions | Secondary Context Surface | Decide whether a new capture is safe to start or whether the current trustworthy baseline should remain in place | Effective current baseline truth, latest capture truth, next safe action | Historical snapshots, raw run context, low-level capture gaps | Not primary because it is one profile's context page, not the tenant-wide decision queue | Follows baseline maintenance workflow | Removes the need to jump to Monitoring just to learn whether capture is safe |
| Baseline compare landing availability and guidance | Primary Decision Surface | Decide whether compare can run now or whether prerequisite work is required first | Assigned profile, consumable baseline truth, dominant block reason, next action | Compare matrix, related run detail, raw artifact history | Primary because it is the tenant-scoped decision entry for compare | Follows tenant baseline review workflow | Stops operators from opening matrix or Monitoring first to discover a prerequisite failure |
| Baseline snapshot detail no-data artifact messaging | Secondary Context Surface | Decide whether a captured artifact is trustworthy, historical, or only an audit trace | Lifecycle/usability, produced-run effect, whether the artifact can become current truth | Raw metadata, counts, related run diagnostics | Not primary because it explains an artifact after the operator chooses to inspect it | Follows artifact review after capture | Prevents operators from reading a zero-item artifact as a normal complete baseline |
| Monitoring run detail for baseline capture | Tertiary Evidence / Diagnostics Surface | Understand why the run did or did not produce a usable baseline after execution | Dominant cause, next step, effect on current baseline truth | Raw JSON, numeric counts, low-level inventory references | Not primary because it is investigation after a run exists | Follows Monitoring and audit review workflow | Keeps investigation focused by stating the real capture truth before raw diagnostics |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Baseline profile view capture truth and header actions | Detail / Record | View-first Resource | Capture baseline or keep current trustworthy snapshot | Header-led record view | n/a | Secondary safe actions in existing header grouping order | Existing archive action only, still on detail header with confirmation | `/admin/baseline-profiles` | `/admin/baseline-profiles/{record}` | Workspace context and related tenant assignment context | Baseline profiles / Baseline profile | Whether current baseline truth is still trustworthy and whether a new capture is safe | none |
| Baseline compare landing availability and guidance | Workflow / Start Surface | Explanation-first Action Landing | Run compare or fix the prerequisite first | Single-page workflow entry | forbidden | Pure navigation to compare matrix or related record stays secondary | none | `/admin/t/{tenant}/baseline-compare` | `/admin/t/{tenant}/baseline-compare` | Tenant chip, assigned profile, current baseline state | Baseline compare / Compare | Whether compare is safe to run now and why not if blocked | none |
| Baseline snapshot detail no-data artifact messaging | Detail / Record | Immutable Artifact Detail | Open related record or accept that the artifact is only historical/no-data evidence | Record detail page | required from list | Related-record links only | none | `/admin/baseline-snapshots` | `/admin/baseline-snapshots/{record}` | Workspace context, related profile, producing run | Baseline snapshots / Baseline snapshot | Whether the artifact is consumable current truth, historical, or only a no-data trace | Existing immutable-artifact exemption |
| Monitoring run detail for baseline capture | Detail / Diagnostics | Operation Run Detail | Open related baseline profile or rerun inventory/capture with the right prerequisite | Run detail page | n/a | Related artifact/navigation actions stay secondary | none | `/admin/operations` | `/admin/operations/{run}` | Workspace context plus tenant context when the run is tenant-bound | Operations / Operation run | Why the run did not produce a usable baseline and what effect it had on current baseline truth | Existing Monitoring diagnostics exemption |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Baseline profile view capture truth and header actions | Workspace baseline manager | Decide whether to start a capture now | Detail / Record | Will a new capture produce a trustworthy baseline or should I fix prerequisites first? | Current effective snapshot truth, latest capture result, prerequisite summary, next step | Raw gap details, low-level inventory run metadata | execution outcome, artifact usability, lifecycle/history | Microsoft tenant + TenantPilot | Capture baseline; Compare now | Archive baseline profile |
| Baseline compare landing availability and guidance | Tenant operator | Decide whether compare can start now | Workflow / Start Surface | Can I trust the current baseline enough to compare this tenant right now? | Assigned profile, baseline availability, blocking reason or readiness message, next action | Compare matrix deep detail, raw run diagnostics | artifact usability, readiness, execution history | simulation only | Compare now; Open compare matrix | none |
| Baseline snapshot detail no-data artifact messaging | Workspace baseline manager | Decide whether a specific artifact can be trusted or safely ignored as historical/no-data evidence | Detail / Record | Is this snapshot a usable current baseline, a historical artifact, or a no-data trace? | Lifecycle/usability, producing run effect, current-vs-historical truth | Raw metadata, raw counts, low-level subject resolution detail | artifact usability, lifecycle/history | TenantPilot only | Open related record | none |
| Monitoring run detail for baseline capture | Workspace operator | Diagnose the real cause of a non-usable capture result | Detail / Diagnostics | Why did this capture not give me a trustworthy baseline? | Dominant reason, next step, whether current baseline changed | Raw JSON, low-level counts, internal IDs | execution outcome, readiness, artifact usability | read-only | Open related profile; Open related snapshot if present | none |
## 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?**: yes, a bounded extension of the existing baseline capture reason-code family
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: A core governance workflow can report success without a trustworthy baseline, which directly misleads operators and auditors.
- **Existing structure is insufficient because**: The current shape checks capture completion too loosely and lets status/outcome, snapshot existence, and compare availability drift apart across start surfaces, Monitoring, and artifact truth.
- **Narrowest correct implementation**: Reuse existing `BaselineSnapshot` lifecycle/usability semantics and existing Ops UX summary/explanation builders, then add only the missing eligibility rules, reason codes, and promotion guardrails for baseline capture.
- **Ownership cost**: Small ongoing cost in one reason-code family, one translation path, a handful of presenter branches, and focused baseline/Monitoring regression tests.
- **Alternative intentionally rejected**: A page-local copy fix or a generic artifact-truth framework. The first would leave contradictory behavior active elsewhere; the second would import much more structure than the problem needs.
- **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 remain out of scope unless implementation proves that one existing historical baseline-capture path must be backfilled deliberately.
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 change is proved by baseline capture outcome mapping, compare-start availability, and Monitoring run-detail truth on existing runtime paths. Focused feature coverage is the narrowest sufficient proof; no browser or heavy-governance lane is needed.
- **New or expanded test families**: Expand baseline capture service/start-surface coverage, compare availability coverage, Monitoring baseline-capture run-detail truth coverage, and one positive plus one negative authorization case on affected surfaces.
- **Fixture / helper cost impact**: Low-to-moderate. Tests can reuse current baseline/inventory fixtures but need explicit seeded inventory-run outcomes for no inventory, blocked inventory, failed inventory, valid-zero-subjects, and previous-good-snapshot preservation.
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: mixed: standard-native-filament + monitoring-state-page
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, but it must include both profile-level action surfaces and Monitoring run detail so explanation and promotion truth cannot drift apart.
- **Reviewer handoff**: Confirm that no test still encodes "empty capture succeeds", that the last trustworthy snapshot remains current after blocked/no-data capture paths, that Monitoring leads with the dominant explanation before raw JSON, and that 404 vs 403 behavior is preserved on the touched surfaces.
- **Budget / baseline / trend impact**: Low increase in baseline and Monitoring feature assertions only; no new heavy or browser baseline expected.
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Baselines/BaselineCaptureTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php tests/Feature/Monitoring/AuditCoverageGovernanceTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Block false-green capture starts (Priority: P1)
As a baseline manager, I need baseline capture to stop telling me it succeeded when there was no credible inventory basis to build from.
**Why this priority**: This is the core trust repair. If the feature does not eliminate false-green capture outcomes, the main problem remains.
**Independent Test**: Can be fully tested by seeding baseline profiles with no inventory run, blocked inventory run, and failed inventory run, then asserting that capture start/execution returns deterministic blocked truth and never advances effective baseline state.
**Acceptance Scenarios**:
1. **Given** an active baseline profile and no relevant inventory sync for the target tenant, **When** a baseline manager tries to capture a new baseline, **Then** the operator-facing result explains that inventory must run first, and the resulting truth never lands on `succeeded`.
2. **Given** an active baseline profile and the latest relevant inventory sync ended `blocked` or `failed`, **When** capture is started, **Then** the capture result is blocked with a baseline-capture-specific reason code, and the previously effective complete snapshot remains current baseline truth.
---
### User Story 2 - Keep no-data captures visible but non-authoritative (Priority: P2)
As a baseline manager, I need a zero-subject capture to be visible as an auditable event without being treated like a trustworthy current baseline.
**Why this priority**: Zero-subject captures are the sharpest form of silent false reassurance after bad upstream prerequisites.
**Independent Test**: Can be fully tested by capturing against valid inventory that resolves zero in-scope subjects and verifying partial success, no promotion of baseline truth, and no-data artifact messaging where an artifact exists.
**Acceptance Scenarios**:
1. **Given** a credible inventory basis but zero subjects in the effective baseline scope, **When** capture runs, **Then** the run ends `partially_succeeded` with a stable `zero_subjects` reason code and does not replace the current consumable snapshot.
2. **Given** a previous complete snapshot exists and a later capture resolves zero in-scope subjects, **When** operators inspect the profile, snapshot, or compare-start surface, **Then** the product still points to the earlier trustworthy snapshot as current baseline truth and renders the newer result only as no-data evidence.
---
### User Story 3 - Explain all-zero capture truth on Monitoring surfaces (Priority: P3)
As an operator reviewing capture history, I need Monitoring to tell me why a capture produced no usable baseline before showing raw counts or JSON.
**Why this priority**: Even with corrected outcomes, operators will still lose trust if Monitoring forces them to decode all-zero counts manually.
**Independent Test**: Can be fully tested by opening seeded baseline capture runs in Monitoring and asserting that the dominant cause and next step are visible before diagnostics.
**Acceptance Scenarios**:
1. **Given** a baseline capture run was blocked because the latest inventory sync failed, **When** an operator opens the Monitoring run detail page, **Then** the page leads with that blocked prerequisite and the next step before raw diagnostics.
2. **Given** a baseline capture run technically completed processing but resolved zero in-scope subjects, **When** an operator opens the Monitoring run detail page, **Then** the page states that no usable baseline was captured, explains the zero-subject result, and clarifies that current baseline truth was not advanced.
### Edge Cases
- The latest relevant inventory sync is blocked or failed, but an older successful inventory sync still exists. The system must not silently fall back to the older success in V1.
- A zero-subject capture may persist a snapshot row or related artifact for audit purposes. If it does, that artifact must remain non-consumable and visibly marked as no-data evidence.
- A blocked or no-data capture can occur while a prior complete snapshot is still current. Operator surfaces must show that the old trustworthy snapshot remains effective.
- A capture can be preflight-blocked on the start surface and still need the same protection at execution time if prerequisite state changes after page load.
- Scheduled or initiator-null capture runs must keep current Ops UX behavior: no terminal DB notification, Monitoring remains the audit surface, and the same dominant-cause explanation still applies.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature changes existing baseline capture runtime behavior and operator truth but introduces no new Microsoft Graph endpoint, no new `OperationRun` type, and no new contract-registry object family. Existing capture start actions stay confirmation-gated, execution remains auditable and observable, and tenant/workspace isolation remains unchanged.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature does not add new persistence or a new abstraction layer. It extends an existing reason-code family because the current baseline capture truth is too weak and because page-local messaging would duplicate semantics across profile, compare, snapshot, and Monitoring surfaces.
**Constitution alignment (XCUT-001):** This is a cross-cutting interaction slice. It must extend the existing baseline reason/translation/stats path and the existing Ops UX run-summary path. No page may introduce a parallel local explanation language for blocked or no-data baseline capture outcomes.
**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature coverage for baseline capture service/start surfaces and Monitoring run detail. No new heavy-governance or browser coverage is required. New fixtures must remain explicit and scenario-driven rather than becoming default test setup.
**Constitution alignment (OPS-UX):** Existing `baseline_capture` runs continue to use the three-surface feedback contract: start-intent feedback, active progress surfaces, and one terminal DB notification for interactive runs. `OperationRun.status` and `OperationRun.outcome` remain service-owned via `OperationRunService`. `summary_counts` remain numeric-only and compliant with existing summary-count rules. Scheduled or initiator-null runs still skip terminal DB notification. Regression coverage must prove that blocked/no-data capture truth does not bypass service-owned transitions.
**Constitution alignment (RBAC-UX):** The affected planes are admin `/admin` for baseline profile/snapshot surfaces, tenant-context `/admin/t/{tenant}/baseline-compare` for compare availability, and canonical `/admin/operations/{run}` Monitoring for capture detail. Non-members or non-entitled tenant viewers continue to receive 404. Members lacking the required current capability continue to receive 403. Server-side enforcement remains authoritative on every touched action/start surface. No raw capability strings or role-name checks may be introduced.
**Constitution alignment (OPS-EX-AUTH-001):** No `/auth/*` behavior is added or broadened.
**Constitution alignment (BADGE-001):** Any changed availability or no-data wording must stay centralized through existing baseline reason/badge/presenter semantics rather than ad-hoc page mappings.
**Constitution alignment (UI-FIL-001):** The feature reuses existing native Filament header actions, detail sections, and shared presenters. No local replacement markup is introduced for badges, alerts, or status language.
**Constitution alignment (UI-NAMING-001):** Primary operator-facing language must stay baseline-domain specific and consistent across buttons, modals, Monitoring summaries, notifications, and audit prose: `Capture baseline`, `Compare now`, `Run tenant sync first`, `Latest inventory sync failed`, `No subjects were in scope`, and `No usable baseline was captured`.
**Constitution alignment (DECIDE-001):** This feature does not add a new primary surface. It hardens one existing primary decision surface (`BaselineCompareLanding`), two secondary context surfaces (profile/snapshot detail), and one tertiary diagnostics surface (Monitoring run detail) so the first decision is based on trustworthy baseline truth rather than inferred success.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The action hierarchy stays unchanged. Navigation remains separate from mutation. Existing compare and capture header actions keep their current placement. No new action groups or destructive actions are introduced.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Existing visible safe header actions remain meaningful and bounded: one capture action and one compare action stay peer visible actions, mode-specific full-content labels replace the default labels instead of adding extra peer actions, and additional safe actions stay grouped under `More`. No new mixed catch-all action groups are added.
**Constitution alignment (OPSURF-001):** The default-visible truth on touched surfaces must stay operator-first: current trustworthy baseline, dominant failure/no-data cause, next action, and effect on baseline truth before raw implementation detail.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature extends existing explanation layers because direct mapping from `status = completed` or zero counts to the UI is insufficient. It must not create a second artifact-truth source beside the existing snapshot lifecycle/usability contract.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Each touched Filament surface keeps one primary inspect/open model, no redundant view actions are added, no empty `ActionGroup` placeholders are introduced, and destructive-action placement remains unchanged.
**Constitution alignment (UX-001 — Layout & Information Architecture):** Existing profile/snapshot view layouts and explanation-first workflow layouts remain in place. This feature changes explanation and availability truth, not overall screen layout.
### Functional Requirements
- **FR-235-001**: Baseline capture eligibility MUST evaluate the most recent relevant inventory sync for the same workspace and target tenant scope using terminal outcome and coverage usability, not `status = completed` alone.
- **FR-235-002**: The system MUST treat each of the following as non-credible capture prerequisites with deterministic baseline-capture reason codes in `BaselineReasonCodes`: no relevant inventory sync exists, the latest relevant inventory sync is `blocked`, the latest relevant inventory sync is `failed`, or the latest relevant inventory sync lacks usable coverage for baseline subject resolution.
- **FR-235-003**: Baseline capture start surfaces MUST preflight known non-credible inventory prerequisites and explain the block with the same reason-code family used at runtime. Server-side capture execution remains authoritative if prerequisite state changes after page load.
- **FR-235-004**: When baseline capture is attempted without a credible inventory basis, the resulting operator truth MUST never land on `succeeded`. The terminal capture truth MUST use a blocked prerequisite outcome with the matching baseline-capture reason code.
- **FR-235-005**: `Succeeded` MUST be reserved for capture runs that produce or reuse a consumable baseline snapshot backed by at least one resolved in-scope subject and that leave effective baseline truth anchored to that consumable snapshot.
- **FR-235-006**: When inventory is credible but zero in-scope subjects resolve, baseline capture MUST finish as `partially_succeeded` with a stable `baseline.capture.zero_subjects` reason code.
- **FR-235-007**: A zero-subject capture MUST NOT advance `active_snapshot_id` or any equivalent effective-baseline pointer. The previously effective complete snapshot, if one exists, remains current baseline truth.
- **FR-235-008**: If implementation persists a snapshot row or related artifact for a zero-subject capture, that artifact MUST reuse the existing snapshot lifecycle/usability contract, remain non-consumable by default, render as a no-data capture artifact on operator surfaces, and never become current baseline truth automatically.
- **FR-235-009**: Capture eligibility in V1 MUST NOT silently fall back to an older successful inventory sync when a newer relevant inventory sync is blocked, failed, or otherwise non-credible. Older successful inventory runs may be shown as historical context only.
- **FR-235-010**: Monitoring run detail for baseline capture MUST lead with one dominant operator-safe explanation and next action before raw JSON, low-level counts, or internal identifiers.
- **FR-235-011**: Baseline capture run context, summary, and audit prose MUST record the chosen eligibility decision, the upstream inventory run reference when present, the terminal baseline-capture reason code, and whether current baseline truth changed.
- **FR-235-012**: Existing baseline compare availability surfaces, including `BaselineCompareLanding` and profile-level compare affordances, MUST derive availability from effective consumable baseline truth after hardened capture outcomes, not from snapshot existence or latest capture completion alone.
- **FR-235-013**: The feature MUST keep copy and translation centralized by extending `BaselineReasonCodes`, `ReasonTranslator`, `BaselineCompareStats`, and the existing Ops UX summary/explanation path rather than adding page-local message branches.
- **FR-235-014**: Existing `Capture baseline` and `Capture baseline (full content)` actions MUST remain confirmation-gated, capability-enforced, and placed on their current surfaces. This feature changes truth and explanation only, not action topology.
- **FR-235-015**: Regression coverage MUST replace existing assumptions that an empty or all-zero baseline capture is a benign success and MUST cover no inventory, blocked inventory, failed inventory, zero-subject capture, and preservation of the previously trustworthy snapshot.
### Assumptions
- Existing snapshot lifecycle/usability semantics from Spec 159 remain the baseline artifact truth source.
- Existing governance run-summary/explanation primitives from Spec 220 remain the Monitoring explanation path.
- The product chooses strict truthful capture behavior in V1: no silent stale-inventory fallback.
- Zero-subject results may remain visible as audit evidence, but they do not become authoritative baseline truth.
### Dependencies and Related Specs
- Spec 159 (`baseline-snapshot-truth`) remains the source of truth for snapshot lifecycle/usability semantics.
- Spec 220 (`governance-run-summaries`) remains the shared Monitoring explanation path for dominant-cause run detail.
- Specs 116-119 remain the shipped baseline drift/cutover foundation that this spec hardens on the capture side.
- Existing baseline compare availability and reason translation paths remain in scope for reuse, not redesign.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Baseline profile view | `app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | One visible capture action (`Capture baseline` or `Capture baseline (full content)` depending on mode), one visible compare action (`Compare now` or `Compare now (full content)` depending on mode), plus secondary safe actions grouped under `More` | Existing list `recordUrl()` to view page | Existing `View` / `Edit` pattern unchanged | None | Existing create CTA unchanged on list | Same visible capture/compare actions plus existing related navigation and grouped secondary actions | Existing save/cancel unchanged | Yes | Capture/compare actions stay confirmation-gated and capability-enforced. Capture baseline remains the primary visible header action; Compare now remains a justified visible secondary safe action on this record page because the operator's same-context decision is whether to refresh baseline truth first or use the current trustworthy snapshot immediately. Mode-specific full-content labels replace the default labels rather than adding extra peer header actions. Existing archive action remains the only destructive action and still requires confirmation. |
| Baseline compare landing | `app/Filament/Pages/BaselineCompareLanding.php` | `Compare now`, `Compare now (full content)` | n/a | None | None | Existing `Open compare matrix` remains the single empty/blocked-state CTA where applicable | n/a | n/a | Yes, via compare run/audit | This feature changes readiness and blocking guidance only. The page remains the primary compare decision/start surface. |
| Baseline snapshot view | `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` | None | Existing clickable list row | Existing related-record navigation only | None | None by design | Existing related-record actions only | n/a | No direct mutation audit | Immutable-resource exemption remains. This feature changes lifecycle/usability explanation for no-data artifacts only. |
| Monitoring run detail | Existing Monitoring run detail surface resolved through `OperationUxPresenter` | Existing related navigation only | n/a | n/a | n/a | n/a | Existing related navigation only | n/a | Yes | No new actions are introduced. The body must show the dominant baseline-capture truth before diagnostics. |
### Key Entities *(include if feature involves data)*
- **BaselineProfile**: The workspace-owned governance definition whose effective baseline truth must remain anchored to the last consumable snapshot.
- **BaselineSnapshot**: The captured baseline artifact whose existing lifecycle/usability semantics determine whether it can become current baseline truth.
- **Inventory Sync OperationRun**: The upstream tenant-scoped execution record whose credibility determines whether baseline capture may trust the current inventory basis.
- **Baseline Capture OperationRun**: The execution record that communicates blocked, partial, or successful capture truth to operators, Monitoring, audit, and notifications.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-235-001**: In focused regression coverage, 100% of baseline-capture attempts without a credible inventory basis end without `succeeded` and expose a deterministic baseline-capture reason code.
- **SC-235-002**: In focused regression coverage, 100% of valid-zero-subject capture scenarios end `partially_succeeded`, do not advance effective baseline truth, and preserve the previously consumable snapshot when one exists.
- **SC-235-003**: On the default-visible baseline profile, compare landing, snapshot detail, and Monitoring run-detail surfaces touched by this feature, operators can identify the dominant cause and next step without opening raw diagnostics.
- **SC-235-004**: No automated test path or default-visible operator surface treats an all-zero baseline capture as an unconditional successful baseline refresh after this feature lands.
- **SC-235-005**: Compare availability remains aligned to effective consumable baseline truth after every covered blocked or no-data capture regression path.

View File

@ -0,0 +1,231 @@
# Tasks: Baseline Capture Truthful Outcomes and Upstream Guardrails
**Input**: Design documents from `/specs/235-baseline-capture-truth/`
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`
**Tests**: Required. This feature changes runtime behavior and operator truth on an existing queued workflow, so Pest coverage must be added or updated in `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php`, `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php`, `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`, and `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`. `apps/platform/tests/Feature/Baselines/BaselineSnapshotBackfillTest.php` remains conditional and is only updated if implementation proves historical empty complete snapshots still affect current runtime truth.
**Operations**: Existing `baseline_capture` `OperationRun` remains canonical. Tasks below explicitly preserve the Ops-UX 3-surface feedback contract, keep `OperationRun.status` and `OperationRun.outcome` service-owned through `apps/platform/app/Services/OperationRunService.php`, keep `summary_counts` flat and numeric-only, avoid any queued/running DB notification drift, and preserve initiator-null Monitoring-only behavior for scheduled/system runs.
**RBAC**: No new authorization model is introduced, but the touched admin `/admin`, tenant-context `/admin/t/{tenant}/baseline-compare`, and Monitoring `/admin/operations/{run}` surfaces must preserve current capability enforcement and `404` versus `403` semantics through existing helpers and regression coverage in `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`.
**UI Naming**: Operator-facing copy must remain baseline-domain specific and centralized through `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php`. No task may introduce page-local copy branches for blocked or no-data baseline capture truth.
**Cross-Cutting Shared Pattern Reuse**: This is a shared interaction slice. Extend `apps/platform/app/Support/Baselines/BaselineReasonCodes.php`, `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php` before considering any local UI branching.
**UI / Surface Guardrails**: `review-mandatory` slice. Surfaces stay `native` Filament plus shared baseline/Ops UX primitives. Required coverage is `standard-native-filament` plus `monitoring-state-page`; no exception path is planned.
**Filament UI Action Surfaces**: No new Resource, Page, or destructive action is introduced. `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, and `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` keep their current action topology, existing confirmation-gated capture actions, and current global-search-disabled posture.
**Badges**: No new badge domain or outcome family is introduced. Existing blocked and `partially_succeeded` semantics remain centralized through current outcome and Ops UX renderers; avoid ad-hoc badge/status mappings in Filament.
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1` then `US2` then `US3`, because zero-subject and Monitoring truth depend on the shared reason-code and run-context groundwork from the false-green capture fix.
## Test Governance Checklist
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [X] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [X] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [X] Planned validation commands cover the change without pulling in unrelated lane cost.
- [X] The declared surface test profile or `standard-native-filament` relief is explicit.
- [X] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Shared Anchors)
**Purpose**: Lock the implementation anchors and proving commands before touching runtime truth.
- [X] T001 [P] Verify the feature anchor inventory across `apps/platform/app/Services/Baselines/BaselineCaptureService.php`, `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`, `apps/platform/app/Support/Baselines/BaselineReasonCodes.php`, `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, and `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`
- [X] T002 [P] Verify the narrow proving commands, guardrail class, and validation-lane expectations in `specs/235-baseline-capture-truth/plan.md` and `specs/235-baseline-capture-truth/quickstart.md`
**Checkpoint**: Runtime anchors and proof commands are locked before implementation begins.
---
## Phase 2: Foundational (Blocking Truth Boundaries)
**Purpose**: Audit the shared boundaries that every story depends on so the implementation does not widen or drift into local fixes.
**CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 [P] Audit latest relevant inventory lookup, terminal-outcome interpretation, and no-stale-fallback boundaries in `apps/platform/app/Services/Baselines/BaselineCaptureService.php` and `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`
- [X] T004 [P] Audit current baseline promotion and consumability boundaries in `apps/platform/app/Models/BaselineProfile.php`, `apps/platform/app/Models/BaselineSnapshot.php`, and `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`
- [X] T005 [P] Audit shared explanation and copy consumers across `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, and `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`
- [X] T006 [P] Audit existing `baseline_capture` run-truth enforcement in `apps/platform/app/Services/OperationRunService.php`, `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`, and `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` so service-owned transitions, numeric `summary_counts`, and `404` versus `403` expectations are explicit before story work begins
**Checkpoint**: The shared inventory, artifact-truth, Ops-UX, and auth boundaries are explicit and safe to extend.
---
## Phase 3: User Story 1 - Block False-Green Capture Starts (Priority: P1) 🎯 MVP
**Goal**: Prevent baseline capture from reporting success when the latest relevant inventory basis is missing, blocked, failed, or otherwise non-credible.
**Independent Test**: Seed no inventory, blocked latest inventory, failed latest inventory, unusable coverage, and after-enqueue drift scenarios, then prove capture start or execution never lands on `succeeded` and never advances effective baseline truth.
### Tests for User Story 1
- [X] T007 [P] [US1] Expand `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php` for no inventory, blocked latest inventory, failed latest inventory, unusable coverage, after-enqueue prerequisite drift, older-success-does-not-fallback, and clean consumable-success scenarios including the success-path run-context and audit metadata contract
- [X] T008 [P] [US1] Expand `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` for shared preflight blocking copy and preserved confirmation-gated capture action topology on `Capture baseline` and `Capture baseline (full content)` actions
- [X] T009 [P] [US1] Expand `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php` to preserve the authorized happy-path access proof plus `404` versus `403` semantics on the profile capture explanation path and related Monitoring/detail surfaces touched by blocked prerequisite truth
### Implementation for User Story 1
- [X] T010 [P] [US1] Extend `apps/platform/app/Support/Baselines/BaselineReasonCodes.php` with deterministic capture-prerequisite reason codes for missing inventory, blocked inventory, failed inventory, unusable coverage, and `baseline.capture.zero_subjects`
- [X] T011 [P] [US1] Extend `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php` with centralized operator-safe wording and next steps for `Run tenant sync first`, `Latest inventory sync failed`, `Latest inventory sync was blocked`, unusable-coverage outcomes, and `No subjects were in scope`
- [X] T012 [US1] Implement latest relevant inventory eligibility preflight in `apps/platform/app/Services/Baselines/BaselineCaptureService.php` so known non-credible prerequisites short-circuit before `OperationRun` creation
- [X] T013 [US1] Re-check latest relevant inventory inside `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` and resolve blocked terminal truth through `apps/platform/app/Services/OperationRunService.php` with numeric `summary_counts`, upstream inventory run reference, chosen eligibility decision, terminal reason code, current-baseline-change flag, and no queued/running DB-notification drift
- [X] T014 [US1] Update `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` and any capture-result explanation consumers to use the shared translated prerequisite truth while preserving existing confirmation-gated action placement and capability enforcement
**Checkpoint**: User Story 1 is independently functional and capture can no longer false-green on non-credible latest inventory truth.
---
## Phase 4: User Story 2 - Keep No-Data Captures Visible but Non-Authoritative (Priority: P2)
**Goal**: Let zero-subject captures remain auditable without allowing them to replace the current trustworthy baseline.
**Independent Test**: Capture against credible inventory that resolves zero in-scope subjects and verify `partially_succeeded`, no promotion of baseline truth, and clear no-data artifact messaging on affected surfaces.
### Tests for User Story 2
- [X] T015 [P] [US2] Expand `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php` for zero-subject capture, preserved previous consumable snapshot, and absence of `succeeded` when no usable baseline was captured
- [X] T016 [P] [US2] Expand `apps/platform/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php` for no-data artifact messaging, current-versus-historical truth on profile and snapshot explanation surfaces, and profile-level compare affordance guidance after blocked or no-data capture outcomes
- [X] T017 [P] [US2] Expand `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php` for prior-trustworthy-baseline preservation versus no-current-baseline guidance after blocked latest-inventory and zero-subject capture outcomes
### Implementation for User Story 2
- [X] T018 [US2] Refactor zero-subject handling in `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` to short-circuit before existing consumable snapshot reuse or `active_snapshot_id` promotion, emit `OperationRunOutcome::PartiallySucceeded`, and record the no-data reason, `baseline_capture.subjects_total`, `result.snapshot_lifecycle` when present, plus effect on current baseline truth
- [X] T019 [US2] Reuse existing lifecycle/usability semantics in `apps/platform/app/Models/BaselineSnapshot.php` and the finalization payloads from `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php` so any zero-subject artifact stays non-consumable and stores no-data finalization metadata instead of a new lifecycle state
- [X] T020 [US2] Preserve `BaselineProfile::resolveCurrentConsumableSnapshot()` behavior in `apps/platform/app/Models/BaselineProfile.php` and update `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` plus `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` to distinguish current trustworthy baseline from no-data evidence
- [X] T021 [US2] Update `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, and profile-level compare affordances in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` so compare availability derives from effective consumable baseline truth after blocked, failed, or no-data capture outcomes rather than latest capture completion or snapshot existence alone
- [X] T022 [US2] If implementation proves historical empty complete snapshots still influence current runtime truth, adjust the relevant legacy finalization path and `apps/platform/tests/Feature/Baselines/BaselineSnapshotBackfillTest.php` inside the same slice; otherwise record in `specs/235-baseline-capture-truth/quickstart.md` and the active PR close-out note that no compatibility backfill change was required
**Checkpoint**: User Story 2 is independently functional and zero-subject capture remains visible without becoming authoritative baseline truth.
---
## Phase 5: User Story 3 - Explain All-Zero Capture Truth On Monitoring Surfaces (Priority: P3)
**Goal**: Ensure Monitoring leads with the dominant blocked or no-data explanation before raw counts or JSON.
**Independent Test**: Open seeded blocked and zero-subject baseline capture runs in Monitoring and verify the dominant cause and next step appear before diagnostics.
### Tests for User Story 3
- [X] T023 [P] [US3] Expand `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php` for blocked latest inventory, failed latest inventory, after-enqueue drift, and zero-subject no-usable-baseline headlines
- [X] T024 [P] [US3] Expand `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php`, and `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php` for consistent blocked or `partially_succeeded` baseline-capture truth, audit summary wording including whether current baseline truth changed, and initiator-aware terminal notification behavior
### Implementation for User Story 3
- [X] T025 [P] [US3] Extend `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` with dominant explanation and next-step branches for blocked latest inventory, after-enqueue drift, failed inventory, unusable coverage, and zero-subject no-data capture
- [X] T026 [US3] Reconcile shared run-detail and operator-explanation consumption across `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php` so Monitoring, compare landing, and audit prose use the same baseline-domain vocabulary and explicitly communicate whether current baseline truth changed without page-local fallbacks
- [X] T027 [US3] Update `apps/platform/app/Notifications/OperationRunCompleted.php` and any baseline-capture completion payload helpers so terminal notification copy reflects the same dominant reason, communicates whether current baseline truth changed, and preserves initiator-aware delivery rules for interactive versus initiator-null runs
**Checkpoint**: User Story 3 is independently functional and Monitoring explains blocked/no-data baseline truth before diagnostics.
---
## Phase 6: Polish & Cross-Cutting Validation
**Purpose**: Finish formatting, verify there is no local truth drift, and run the narrow proving pack.
- [X] T028 [P] Search the touched runtime and surface files `apps/platform/app/Services/Baselines/BaselineCaptureService.php`, `apps/platform/app/Jobs/CaptureBaselineSnapshotJob.php`, `apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php`, `apps/platform/app/Support/Baselines/BaselineCompareStats.php`, `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/Ui/OperatorExplanation/OperatorExplanationBuilder.php` to confirm no page-local explanation branches or stale-success fallback logic remain
- [X] T029 Run formatting for all touched PHP and test files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [X] T030 [P] Run the baseline capture and Filament surface validation pack from `specs/235-baseline-capture-truth/quickstart.md` against `apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php`, and `apps/platform/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php`
- [X] T031 [P] Run the Monitoring, audit/notification, and authorization validation pack from `specs/235-baseline-capture-truth/quickstart.md` against `apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`, `apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php`, `apps/platform/tests/Feature/Monitoring/AuditCoverageGovernanceTest.php`, `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php`, and `apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php`
- [X] T032 [P] Run `apps/platform/tests/Feature/Baselines/BaselineSnapshotBackfillTest.php` only if `T022` changed legacy empty-snapshot classification behavior
- [X] T033 Record the Guardrail close-out entry in `specs/235-baseline-capture-truth/quickstart.md` and the active PR description for `Guardrail` status, `standard-native-filament` plus `monitoring-state-page` coverage, and whether `T022` resolved as `document-in-feature` or required a follow-up
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and locks anchors plus proving commands.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until shared truth boundaries are explicit.
- **User Story 1 (Phase 3)**: Depends on Foundational and is the MVP cut.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because zero-subject truth builds on the new reason-code and eligibility groundwork in the same capture workflow.
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because Monitoring must reflect the final blocked and no-data truth contract.
- **Polish (Phase 6)**: Depends on all completed story work.
### User Story Dependencies
- **US1**: No dependency beyond Foundational.
- **US2**: Depends on US1 shared reason-code, run-context, and preflight/runtime truth changes.
- **US3**: Depends on US1 and US2 shared truth being complete.
### Within Each User Story
- Write the story tests first and confirm they fail before implementation is considered complete.
- Keep copy and explanation centralized through shared baseline and Ops UX helpers.
- Preserve current confirmation-gated action topology and existing capability enforcement.
- Preserve `OperationRunService` ownership of terminal status and outcome transitions.
- Finish story-level validation before moving to the next dependent story.
### Parallel Opportunities
- `T001` and `T002` can run in parallel during Setup.
- `T003`, `T004`, `T005`, and `T006` can run in parallel during Foundational work.
- `T007`, `T008`, and `T009` can run in parallel for User Story 1; `T010` and `T011` can also proceed in parallel before `T012` through `T014`.
- `T015`, `T016`, and `T017` can run in parallel for User Story 2 before the implementation sequence `T018` through `T022`.
- `T023` and `T024` can run in parallel for User Story 3 before `T025` through `T027`.
- `T030`, `T031`, and `T032` can run in parallel during final validation when their prerequisites are satisfied.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel
T007 apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php
T008 apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php
T009 apps/platform/tests/Feature/Authorization/OperatorExplanationSurfaceAuthorizationTest.php
# Shared reason and translation groundwork in parallel
T010 apps/platform/app/Support/Baselines/BaselineReasonCodes.php
T011 apps/platform/app/Support/ReasonTranslation/ReasonTranslator.php
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel
T015 apps/platform/tests/Feature/Baselines/BaselineCaptureTest.php
T016 apps/platform/tests/Feature/Filament/BaselineCaptureResultExplanationSurfaceTest.php
T017 apps/platform/tests/Feature/Filament/BaselineCompareLandingStartSurfaceTest.php
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel
T023 apps/platform/tests/Feature/Monitoring/GovernanceOperationRunSummariesTest.php
T024 apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Run `T029` and `T030` before widening the slice.
### Incremental Delivery
1. Ship User Story 1 to eliminate false-green capture starts and runtime success on non-credible latest inventory.
2. Ship User Story 2 to keep zero-subject captures visible but non-authoritative.
3. Ship User Story 3 to align Monitoring with the hardened capture truth.
4. Finish with the final formatting, validation, and guardrail close-out tasks.
### Parallel Team Strategy
1. One contributor can prepare the shared reason-code and translation groundwork while another prepares the User Story 1 proof surfaces.
2. After User Story 1 lands, one contributor can take the zero-subject runtime path while another prepares the User Story 2 surface tests.
3. User Story 3 can start once the blocked and no-data run-context truth is stable enough for Monitoring and Ops UX proof.
---
## Notes
- `[P]` tasks target different files or independent proof surfaces and can be worked in parallel once upstream blockers are cleared.
- `[US1]`, `[US2]`, and `[US3]` map directly to the feature specification user stories.
- No `contracts/` artifact is required for this feature because there is no external API or route contract change.
- The suggested MVP scope is Phase 1 through Phase 3 only.