diff --git a/.codex/prompts/speckit.analyze.md b/.codex/skills/speckit-analyze/SKILL.md similarity index 96% rename from .codex/prompts/speckit.analyze.md rename to .codex/skills/speckit-analyze/SKILL.md index 98b04b0c..37d014ec 100644 --- a/.codex/prompts/speckit.analyze.md +++ b/.codex/skills/speckit-analyze/SKILL.md @@ -1,14 +1,15 @@ --- +name: speckit-analyze description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.analyze.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Goal diff --git a/.codex/prompts/speckit.checklist.md b/.codex/skills/speckit-checklist/SKILL.md similarity index 98% rename from .codex/prompts/speckit.checklist.md rename to .codex/skills/speckit-checklist/SKILL.md index 970e6c9e..9d1f5895 100644 --- a/.codex/prompts/speckit.checklist.md +++ b/.codex/skills/speckit-checklist/SKILL.md @@ -1,7 +1,12 @@ --- +name: speckit-checklist description: Generate a custom checklist for the current feature based on user requirements. +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.checklist.md --- + ## Checklist Purpose: "Unit Tests for English" **CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain. @@ -25,11 +30,7 @@ ## Checklist Purpose: "Unit Tests for English" ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Execution Steps diff --git a/.codex/prompts/speckit.clarify.md b/.codex/skills/speckit-clarify/SKILL.md similarity index 97% rename from .codex/prompts/speckit.clarify.md rename to .codex/skills/speckit-clarify/SKILL.md index 6b28dae1..53fb42f2 100644 --- a/.codex/prompts/speckit.clarify.md +++ b/.codex/skills/speckit-clarify/SKILL.md @@ -1,18 +1,15 @@ --- +name: speckit-clarify description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec. -handoffs: - - label: Build Technical Plan - agent: speckit.plan - prompt: Create a plan for the spec. I am building with... +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.clarify.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Outline diff --git a/.codex/prompts/speckit.constitution.md b/.codex/skills/speckit-constitution/SKILL.md similarity index 94% rename from .codex/prompts/speckit.constitution.md rename to .codex/skills/speckit-constitution/SKILL.md index 18302642..aa063b3f 100644 --- a/.codex/prompts/speckit.constitution.md +++ b/.codex/skills/speckit-constitution/SKILL.md @@ -1,18 +1,15 @@ --- +name: speckit-constitution description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. -handoffs: - - label: Build Specification - agent: speckit.specify - prompt: Implement the feature specification based on the updated constitution. I want to build... +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.constitution.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Outline diff --git a/.codex/prompts/speckit.implement.md b/.codex/skills/speckit-implement/SKILL.md similarity index 97% rename from .codex/prompts/speckit.implement.md rename to .codex/skills/speckit-implement/SKILL.md index 7cb17b02..535b8af4 100644 --- a/.codex/prompts/speckit.implement.md +++ b/.codex/skills/speckit-implement/SKILL.md @@ -1,14 +1,15 @@ --- +name: speckit-implement description: Execute the implementation plan by processing and executing all tasks defined in tasks.md +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.implement.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Outline diff --git a/.codex/prompts/speckit.plan.md b/.codex/skills/speckit-plan/SKILL.md similarity index 89% rename from .codex/prompts/speckit.plan.md rename to .codex/skills/speckit-plan/SKILL.md index a24508b4..6e4fd253 100644 --- a/.codex/prompts/speckit.plan.md +++ b/.codex/skills/speckit-plan/SKILL.md @@ -1,22 +1,15 @@ --- +name: speckit-plan description: Execute the implementation planning workflow using the plan template to generate design artifacts. -handoffs: - - label: Create Tasks - agent: speckit.tasks - prompt: Break the plan into tasks - send: true - - label: Create Checklist - agent: speckit.checklist - prompt: Create a checklist for the following domain... +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.plan.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Outline diff --git a/.codex/prompts/speckit.specify.md b/.codex/skills/speckit-specify/SKILL.md similarity index 95% rename from .codex/prompts/speckit.specify.md rename to .codex/skills/speckit-specify/SKILL.md index 49abdcb7..67c898f5 100644 --- a/.codex/prompts/speckit.specify.md +++ b/.codex/skills/speckit-specify/SKILL.md @@ -1,26 +1,19 @@ --- +name: speckit-specify description: Create or update the feature specification from a natural language feature description. -handoffs: - - label: Build Technical Plan - agent: speckit.plan - prompt: Create a plan for the spec. I am building with... - - label: Clarify Spec Requirements - agent: speckit.clarify - prompt: Clarify specification requirements - send: true +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.specify.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Outline -The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command. +The user's current request is the feature description. Assume it is available in the conversation and do not ask the user to repeat it unless no feature description was provided. Given that feature description, do this: diff --git a/.codex/prompts/speckit.tasks.md b/.codex/skills/speckit-tasks/SKILL.md similarity index 94% rename from .codex/prompts/speckit.tasks.md rename to .codex/skills/speckit-tasks/SKILL.md index f64e86e7..9b45b971 100644 --- a/.codex/prompts/speckit.tasks.md +++ b/.codex/skills/speckit-tasks/SKILL.md @@ -1,23 +1,15 @@ --- +name: speckit-tasks description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. -handoffs: - - label: Analyze For Consistency - agent: speckit.analyze - prompt: Run a project analysis for consistency - send: true - - label: Implement Project - agent: speckit.implement - prompt: Start the implementation in phases - send: true +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.tasks.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Outline diff --git a/.codex/prompts/speckit.taskstoissues.md b/.codex/skills/speckit-taskstoissues/SKILL.md similarity index 77% rename from .codex/prompts/speckit.taskstoissues.md rename to .codex/skills/speckit-taskstoissues/SKILL.md index 07991911..dea4fcfd 100644 --- a/.codex/prompts/speckit.taskstoissues.md +++ b/.codex/skills/speckit-taskstoissues/SKILL.md @@ -1,15 +1,15 @@ --- +name: speckit-taskstoissues description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. -tools: ['github/github-mcp-server/issue_write'] +compatibility: Requires spec-kit project structure with .specify/ directory +metadata: + source: .codex/prompts/speckit.taskstoissues.md --- + ## User Input -```text -$ARGUMENTS -``` - -You **MUST** consider the user input before proceeding (if not empty). +Use the user's current request as input. If the user did not add extra direction, follow the default workflow below. ## Outline diff --git a/.codex/prompts/tenantpilot.audit.md b/.codex/skills/tenantpilot-audit/SKILL.md similarity index 82% rename from .codex/prompts/tenantpilot.audit.md rename to .codex/skills/tenantpilot-audit/SKILL.md index 7b6622f1..1ef5c593 100644 --- a/.codex/prompts/tenantpilot.audit.md +++ b/.codex/skills/tenantpilot-audit/SKILL.md @@ -1,12 +1,19 @@ --- +name: tenantpilot-audit description: Scan the TenantPilot repository for architecture and safety violations against the workspace-first, RBAC-first, audit-first governance model. +compatibility: Requires the TenantPilot/TenantAtlas repository structure with docs/audits and specs/ +metadata: + author: tenantpilot + source: .codex/prompts/tenantpilot.audit.md --- -You are a Senior Staff Engineer and Enterprise SaaS Architecture Auditor reviewing TenantPilot / TenantAtlas. +# TenantPilot Architecture Audit + +Use this skill when the user asks for a TenantPilot or TenantAtlas architecture, safety, RBAC, isolation, or auditability review. This is not a generic code review. Audit the repository against the TenantPilot audit constitution at `docs/audits/tenantpilot-architecture-audit-constitution.md`. -## Audit focus +## Audit Focus Prioritize: @@ -23,7 +30,7 @@ ## Audit focus - unauthorized action coverage - workflow misuse and invalid transition coverage -## Output rules +## Output Rules Classify every finding as exactly one of: @@ -60,7 +67,7 @@ ## Constraints - Group multiple symptoms under one deeper diagnosis when appropriate. - Be explicit when a local fix is insufficient and a dedicated spec is required. -## Repository context +## Repository Context TenantPilot is an enterprise SaaS product for Intune and Microsoft 365 governance, backup, restore, inventory, drift detection, findings, exceptions, operations, and auditability. @@ -73,4 +80,4 @@ ## Repository context - provider access through canonical boundaries - minimal duplication of domain logic across UI surfaces -Return the audit as a concise but substantive findings report. \ No newline at end of file +Return the audit as a concise but substantive findings report. diff --git a/.codex/prompts/tenantpilot.spec-candidates.md b/.codex/skills/tenantpilot-spec-candidates/SKILL.md similarity index 89% rename from .codex/prompts/tenantpilot.spec-candidates.md rename to .codex/skills/tenantpilot-spec-candidates/SKILL.md index 78dac297..72aa49cd 100644 --- a/.codex/prompts/tenantpilot.spec-candidates.md +++ b/.codex/skills/tenantpilot-spec-candidates/SKILL.md @@ -1,8 +1,15 @@ --- +name: tenantpilot-spec-candidates description: Turn TenantPilot architecture audit findings into bounded spec candidates without colliding with active spec numbering. +compatibility: Requires the TenantPilot/TenantAtlas repository structure with docs/audits and specs/ +metadata: + author: tenantpilot + source: .codex/prompts/tenantpilot.spec-candidates.md --- -You are a Senior Staff Engineer and Enterprise SaaS Architect working on TenantPilot / TenantAtlas. +# TenantPilot Spec Candidates + +Use this skill when the user asks to turn TenantPilot or TenantAtlas audit findings into bounded follow-up spec candidates. Your task is to produce spec candidates, not implementation code. @@ -26,13 +33,13 @@ ## Goal 3. findings workflow enforcement and audit backstop 4. Livewire context locking and trusted-state reduction -## Numbering rule +## Numbering Rule - Do not invent or reserve fixed spec numbers unless the current repository state proves they are available. - If numbering is uncertain, use `Candidate A`, `Candidate B`, `Candidate C`, and `Candidate D`. - Only recommend a numbering strategy; do not force numbering in the output when collisions are possible. -## Output requirements +## Output Requirements Create exactly four spec candidates, one per problem class. @@ -63,7 +70,7 @@ ## Output requirements C. Which candidate should start first and why D. A numbering strategy recommendation if active spec numbers are not yet known -## Writing rules +## Writing Rules - Write in English. - Use formal enterprise spec language. @@ -77,7 +84,7 @@ ## Writing rules - Do not duplicate adjacent specs; state the boundary clearly. - Do not collapse all four themes into one umbrella spec. -## Candidate-specific direction +## Candidate-Specific Direction ### Candidate A — queued execution reauthorization and scope continuity @@ -101,4 +108,4 @@ ### Candidate D — Livewire context locking and trusted-state reduction - Treat this as a UI/server trust-boundary hardening problem. - Cover locked identifiers, untrusted public state, server-side reconstruction of workflow truth, sensitive-state reduction, and misuse regression tests. -- Make clear how this complements but does not duplicate Spec 138. \ No newline at end of file +- Make clear how this complements but does not duplicate Spec 138. diff --git a/.specify/init-options.json b/.specify/init-options.json index 7115ce9e..e6d272b8 100644 --- a/.specify/init-options.json +++ b/.specify/init-options.json @@ -1,9 +1,10 @@ { - "ai": "copilot", + "ai": "codex", + "ai_skills": true, "branch_numbering": "sequential", - "context_file": ".github/copilot-instructions.md", + "context_file": "AGENTS.md", "here": true, - "integration": "copilot", + "integration": "codex", "preset": null, "script": "sh", "speckit_version": "0.7.4" diff --git a/.specify/integration.json b/.specify/integration.json index d43cd968..ba731ac0 100644 --- a/.specify/integration.json +++ b/.specify/integration.json @@ -1,4 +1,4 @@ { - "integration": "copilot", + "integration": "codex", "version": "0.7.4" } diff --git a/.specify/integrations/codex.manifest.json b/.specify/integrations/codex.manifest.json new file mode 100644 index 00000000..c6ac07d3 --- /dev/null +++ b/.specify/integrations/codex.manifest.json @@ -0,0 +1,16 @@ +{ + "integration": "codex", + "version": "0.7.4", + "installed_at": "2026-06-06T21:02:24.334747+00:00", + "files": { + ".agents/skills/speckit-analyze/SKILL.md": "9f76cd502bac29c2b61bfce985c6895d8d470e76027302a0d2f8c0be88fdfd94", + ".agents/skills/speckit-checklist/SKILL.md": "2a9c0f32b4d243a1a205121b11e497507b2a9f58ea2fcbbf980ffb89ce8d67bb", + ".agents/skills/speckit-clarify/SKILL.md": "ad31f63f9ed1d2e84f22a5ef0967701cbc827c2473ff2ab073e748e14f74a730", + ".agents/skills/speckit-constitution/SKILL.md": "7310adee465ee6a439a689518e6c133ce851d808c7a6e64d66f659ad8b4bd56e", + ".agents/skills/speckit-implement/SKILL.md": "8e610cc412686173a3ecea85fa78d8b9cc6bfe80c6bdc710e3df53e0c104ef0a", + ".agents/skills/speckit-plan/SKILL.md": "1cd13274eb35a18d87e6f43bc69801910437ce9543286b13f87c4e3cbc33b9d8", + ".agents/skills/speckit-specify/SKILL.md": "019e30edd3f31267b48a9c846311ac07e78661b7599d3a96e94bded5b8f646fe", + ".agents/skills/speckit-tasks/SKILL.md": "a3154ecb04e7ed42608f59f64edea70ac1f3f258a2f3aa794d26edb5da017685", + ".agents/skills/speckit-taskstoissues/SKILL.md": "32d8ee0f0482a7b2b9e6bee4dfd6947465754f2f82c404bfc7ef64f4a3b63bdd" + } +} diff --git a/.specify/integrations/speckit.manifest.json b/.specify/integrations/speckit.manifest.json index 863baec0..05cd7612 100644 --- a/.specify/integrations/speckit.manifest.json +++ b/.specify/integrations/speckit.manifest.json @@ -1,8 +1,6 @@ { "integration": "speckit", "version": "0.7.4", - "installed_at": "2026-04-22T21:58:02.965809+00:00", - "files": { - ".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3" - } + "installed_at": "2026-06-06T21:02:24.340496+00:00", + "files": {} } diff --git a/Agents.md b/Agents.md index 9e5b6a9a..63257df5 100644 --- a/Agents.md +++ b/Agents.md @@ -950,3 +950,8 @@ ## Active Technologies ## Recent Changes - 066-rbac-ui-enforcement-helper-v2-session-1769732329: Planned UiEnforcement v2 (spec + plan + design artifacts) + + +For additional context about technologies to be used, project structure, +shell commands, and other important information, read the current plan + diff --git a/apps/platform/app/Models/OperationRun.php b/apps/platform/app/Models/OperationRun.php index ca61c4b4..9d314305 100644 --- a/apps/platform/app/Models/OperationRun.php +++ b/apps/platform/app/Models/OperationRun.php @@ -414,6 +414,13 @@ public function reconciliationAdapter(): ?string return is_string($adapter) && trim($adapter) !== '' ? trim($adapter) : null; } + public function reconciledRelatedType(): ?string + { + $type = $this->reconciliationRelated()['type'] ?? null; + + return is_string($type) && trim($type) !== '' ? trim($type) : null; + } + /** * @return array */ @@ -426,13 +433,17 @@ public function reconciliationRelated(): array public function reconciledRelatedReviewId(): ?int { - $related = $this->reconciliationRelated(); - $relatedType = is_string($related['type'] ?? null) ? trim((string) $related['type']) : null; - $reviewId = $relatedType === 'environment_review' - ? ($related['id'] ?? null) - : data_get($related, 'review.id'); + return $this->reconciledRelatedIdForType('environment_review', ['review.id']); + } - return is_numeric($reviewId) ? (int) $reviewId : null; + public function reconciledRelatedEvidenceSnapshotId(): ?int + { + return $this->reconciledRelatedIdForType('evidence_snapshot', ['snapshot.id']); + } + + public function reconciledRelatedReviewPackId(): ?int + { + return $this->reconciledRelatedIdForType('review_pack', ['review_pack.id']); } public function isLifecycleReconciled(): bool @@ -447,6 +458,33 @@ public function lifecycleReconciliationReasonCode(): ?string return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null; } + /** + * @param array $legacyPaths + */ + private function reconciledRelatedIdForType(string $type, array $legacyPaths = []): ?int + { + $related = $this->reconciliationRelated(); + $relatedType = $this->reconciledRelatedType(); + + if ($relatedType === $type && is_numeric($related['id'] ?? null)) { + return (int) $related['id']; + } + + if ($relatedType !== null) { + return null; + } + + foreach ($legacyPaths as $path) { + $value = data_get($related, $path); + + if (is_numeric($value)) { + return (int) $value; + } + } + + return null; + } + public function freshnessState(): OperationRunFreshnessState { return OperationRunFreshnessState::forRun($this); diff --git a/apps/platform/app/Services/ReviewPackService.php b/apps/platform/app/Services/ReviewPackService.php index 9921c42e..b662bd61 100644 --- a/apps/platform/app/Services/ReviewPackService.php +++ b/apps/platform/app/Services/ReviewPackService.php @@ -7,11 +7,11 @@ use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; use App\Exceptions\ReviewPackEvidenceResolutionException; use App\Jobs\GenerateReviewPackJob; +use App\Models\EnvironmentReview; use App\Models\EvidenceSnapshot; +use App\Models\ManagedEnvironment; use App\Models\OperationRun; use App\Models\ReviewPack; -use App\Models\ManagedEnvironment; -use App\Models\EnvironmentReview; use App\Models\User; use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; @@ -379,7 +379,7 @@ private function assertReviewPackGenerationAllowed(ManagedEnvironment $tenant): throw new WorkspaceEntitlementBlockedException($decision); } - private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string + public function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string { $data = [ 'managed_environment_id' => (int) $snapshot->managed_environment_id, diff --git a/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php b/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php index 1642f4d2..61034d4f 100644 --- a/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php +++ b/apps/platform/app/Support/Navigation/CrossResourceNavigationMatrix.php @@ -74,7 +74,9 @@ private function rules(): array new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'baseline_profile', 'baseline_profile', 'direct_record', 30, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 40, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'parent_policy', 'policy', 'direct_record', 50, missingStatePolicy: 'hide'), - new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'environment_review', 'environment_review', 'direct_record', 55, missingStatePolicy: 'hide'), + new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'evidence_snapshot', 'evidence_snapshot', 'direct_record', 55, missingStatePolicy: 'hide'), + new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'environment_review', 'environment_review', 'direct_record', 56, missingStatePolicy: 'hide'), + new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'review_pack', 'review_pack', 'direct_record', 57, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_OPERATION_RUN, self::SURFACE_DETAIL_SECTION, 'operations', 'operations', 'canonical_page', 60, missingStatePolicy: 'hide'), new NavigationMatrixRule(self::SOURCE_BASELINE_PROFILE, self::SURFACE_DETAIL_HEADER, 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 10, missingStatePolicy: 'hide'), diff --git a/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php b/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php index 00cf5ed5..09762479 100644 --- a/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php +++ b/apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php @@ -18,9 +18,11 @@ final class RelatedActionLabelCatalog 'backup_set' => 'Backup set', 'current_policy_version' => 'Current policy version', 'environment_review' => 'ManagedEnvironment Review', + 'evidence_snapshot' => 'Evidence snapshot', 'operations' => 'Operations', 'parent_policy' => 'Policy', 'policy_version' => 'Policy version', + 'review_pack' => 'Review pack', 'restore_run' => 'Restore run', 'source_run' => 'Operation', ]; @@ -34,9 +36,11 @@ final class RelatedActionLabelCatalog 'backup_set' => 'View backup set', 'current_policy_version' => 'View policy version', 'environment_review' => 'ManagedEnvironment Review', + 'evidence_snapshot' => 'View evidence snapshot', 'operations' => 'Open operations', 'parent_policy' => 'View policy', 'policy_version' => 'View policy version', + 'review_pack' => 'View review pack', 'restore_run' => 'View restore run', 'source_run' => 'Open operation', ]; diff --git a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php index 2f3e7e8e..f0f8c8dc 100644 --- a/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php +++ b/apps/platform/app/Support/Navigation/RelatedNavigationResolver.php @@ -10,16 +10,19 @@ use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\EnvironmentReviewResource; +use App\Filament\Resources\EvidenceSnapshotResource; use App\Filament\Resources\FindingExceptionResource; use App\Filament\Resources\FindingResource; use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\RestoreRunResource; +use App\Filament\Resources\ReviewPackResource; use App\Models\AuditLog; use App\Models\BackupSet; use App\Models\BaselineProfile; use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshotItem; use App\Models\EnvironmentReview; +use App\Models\EvidenceSnapshot; use App\Models\Finding; use App\Models\FindingException; use App\Models\ManagedEnvironment; @@ -27,6 +30,7 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\RestoreRun; +use App\Models\ReviewPack; use App\Models\User; use App\Models\Workspace; use App\Services\Auth\CapabilityResolver; @@ -509,7 +513,9 @@ private function resolveOperationRunRule(NavigationMatrixRule $rule, OperationRu workspaceId: (int) $run->workspace_id, ), 'parent_policy' => $this->operationRunPolicyEntry($rule, $run), + 'evidence_snapshot' => $this->evidenceSnapshotEntry($rule, $run), 'environment_review' => $this->environmentReviewEntry($rule, $run), + 'review_pack' => $this->reviewPackEntry($rule, $run), 'operations' => $this->operationsEntry( rule: $rule, tenant: $run->tenant, @@ -708,6 +714,78 @@ private function environmentReviewEntry(NavigationMatrixRule $rule, OperationRun ); } + private function evidenceSnapshotEntry(NavigationMatrixRule $rule, OperationRun $run): ?RelatedContextEntry + { + $snapshotId = $run->reconciledRelatedEvidenceSnapshotId(); + $tenant = $run->tenant; + + if ($snapshotId === null || $snapshotId <= 0 || ! $tenant instanceof ManagedEnvironment) { + return null; + } + + $snapshot = EvidenceSnapshot::query() + ->whereKey($snapshotId) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->first(); + + if (! $snapshot instanceof EvidenceSnapshot) { + return $this->unavailableEntry($rule, '#'.$snapshotId, 'missing'); + } + + if (! $this->canOpenTenantRecord($tenant, Capabilities::EVIDENCE_VIEW)) { + return $this->unavailableEntry($rule, '#'.$snapshotId, 'unauthorized'); + } + + return RelatedContextEntry::available( + key: $rule->relationKey, + label: $this->labels->entryLabel($rule->relationKey), + value: 'Snapshot #'.$snapshot->getKey(), + secondaryValue: ucfirst(str_replace('_', ' ', (string) $snapshot->status)), + targetUrl: EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant), + targetKind: $rule->targetType, + priority: $rule->priority, + actionLabel: $this->labels->actionLabel($rule->relationKey), + contextBadge: 'Evidence', + ); + } + + private function reviewPackEntry(NavigationMatrixRule $rule, OperationRun $run): ?RelatedContextEntry + { + $reviewPackId = $run->reconciledRelatedReviewPackId(); + $tenant = $run->tenant; + + if ($reviewPackId === null || $reviewPackId <= 0 || ! $tenant instanceof ManagedEnvironment) { + return null; + } + + $pack = ReviewPack::query() + ->whereKey($reviewPackId) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $tenant->getKey()) + ->first(); + + if (! $pack instanceof ReviewPack) { + return $this->unavailableEntry($rule, '#'.$reviewPackId, 'missing'); + } + + if (! $this->canOpenTenantRecord($tenant, Capabilities::REVIEW_PACK_VIEW)) { + return $this->unavailableEntry($rule, '#'.$reviewPackId, 'unauthorized'); + } + + return RelatedContextEntry::available( + key: $rule->relationKey, + label: $this->labels->entryLabel($rule->relationKey), + value: 'Review pack #'.$pack->getKey(), + secondaryValue: ucfirst(str_replace('_', ' ', (string) $pack->status)), + targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant), + targetKind: $rule->targetType, + priority: $rule->priority, + actionLabel: $this->labels->actionLabel($rule->relationKey), + contextBadge: 'Reporting', + ); + } + private function parentPolicyEntryForFinding(NavigationMatrixRule $rule, Finding $finding): ?RelatedContextEntry { $policyVersionId = $this->findingPolicyVersionId($finding); diff --git a/apps/platform/app/Support/OperationRunLinks.php b/apps/platform/app/Support/OperationRunLinks.php index 12818dcc..f44d2ffb 100644 --- a/apps/platform/app/Support/OperationRunLinks.php +++ b/apps/platform/app/Support/OperationRunLinks.php @@ -219,10 +219,25 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): } if ($canonicalType === 'tenant.evidence.snapshot.generate') { - $snapshot = EvidenceSnapshot::query() - ->where('operation_run_id', (int) $run->getKey()) - ->latest('id') - ->first(); + $snapshot = null; + $relatedSnapshotId = $run->reconciledRelatedEvidenceSnapshotId(); + + if ($relatedSnapshotId !== null) { + $snapshot = EvidenceSnapshot::query() + ->whereKey($relatedSnapshotId) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->first(); + } + + if (! $snapshot instanceof EvidenceSnapshot) { + $snapshot = EvidenceSnapshot::query() + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->where('operation_run_id', (int) $run->getKey()) + ->latest('id') + ->first(); + } if ($snapshot instanceof EvidenceSnapshot) { $links['Evidence Snapshot'] = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant); @@ -243,6 +258,8 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): if (! $review instanceof EnvironmentReview) { $review = EnvironmentReview::query() + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) ->where('operation_run_id', (int) $run->getKey()) ->latest('id') ->first(); @@ -254,10 +271,25 @@ public static function related(OperationRun $run, ?ManagedEnvironment $tenant): } if ($canonicalType === 'environment.review_pack.generate') { - $pack = ReviewPack::query() - ->where('operation_run_id', (int) $run->getKey()) - ->latest('id') - ->first(); + $pack = null; + $relatedReviewPackId = $run->reconciledRelatedReviewPackId(); + + if ($relatedReviewPackId !== null) { + $pack = ReviewPack::query() + ->whereKey($relatedReviewPackId) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->first(); + } + + if (! $pack instanceof ReviewPack) { + $pack = ReviewPack::query() + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->where('operation_run_id', (int) $run->getKey()) + ->latest('id') + ->first(); + } if ($pack instanceof ReviewPack) { $links['Review Pack'] = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant); diff --git a/apps/platform/app/Support/Operations/Reconciliation/EvidenceSnapshotReconciliationAdapter.php b/apps/platform/app/Support/Operations/Reconciliation/EvidenceSnapshotReconciliationAdapter.php new file mode 100644 index 00000000..19c82972 --- /dev/null +++ b/apps/platform/app/Support/Operations/Reconciliation/EvidenceSnapshotReconciliationAdapter.php @@ -0,0 +1,177 @@ +supportsType((string) $run->type)) { + return ReconciliationResult::unsupported( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'This adapter only supports evidence snapshot generation runs.', + evidence: [ + 'adapter' => $this->key(), + 'type' => (string) $run->type, + ], + ); + } + + $context = is_array($run->context) ? $run->context : []; + $workspaceId = (int) ($run->workspace_id ?? $context['workspace_id'] ?? 0); + $tenantId = (int) ($run->managed_environment_id ?? $context['managed_environment_id'] ?? 0); + $fingerprint = is_string($context['fingerprint'] ?? null) + ? trim((string) $context['fingerprint']) + : ''; + + $evidence = [ + 'adapter' => $this->key(), + 'operation_run_id' => (int) $run->getKey(), + 'workspace_id' => $workspaceId, + 'managed_environment_id' => $tenantId, + 'fingerprint' => $fingerprint, + ]; + + if ($workspaceId <= 0 || $tenantId <= 0 || $fingerprint === '') { + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'The run no longer carries enough evidence snapshot scope to reconcile it safely.', + evidence: $evidence, + ); + } + + $snapshot = EvidenceSnapshot::query() + ->where('workspace_id', $workspaceId) + ->where('managed_environment_id', $tenantId) + ->where('fingerprint', $fingerprint) + ->latest('id') + ->first(); + + if (! $snapshot instanceof EvidenceSnapshot) { + return ReconciliationResult::notReconciled( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'No matching evidence snapshot is available yet for this run.', + evidence: $evidence, + ); + } + + $related = $this->relatedSnapshotMetadata($snapshot); + $status = (string) $snapshot->status; + $completeness = (string) $snapshot->completeness_state; + + if ($this->isUsableSnapshot($snapshot)) { + return ReconciliationResult::reconciledSucceeded( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: (int) ($snapshot->operation_run_id ?? 0) === (int) $run->getKey() + ? 'The queued evidence snapshot was already completed before the run finished updating.' + : 'A matching evidence snapshot was already available for this run.', + evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()], + related: $related, + summaryCounts: $this->summaryCounts($snapshot), + ); + } + + if (in_array($status, [ + EvidenceSnapshotStatus::Queued->value, + EvidenceSnapshotStatus::Generating->value, + ], true)) { + return ReconciliationResult::notReconciled( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: (int) ($snapshot->operation_run_id ?? 0) === (int) $run->getKey() + ? 'The queued evidence snapshot is still generating and does not prove final snapshot truth yet.' + : 'A matching evidence snapshot exists, but it is still generating.', + evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()], + related: $related, + ); + } + + $reasonMessage = match (true) { + $status === EvidenceSnapshotStatus::Expired->value => 'A matching evidence snapshot exists, but it is already expired.', + $status === EvidenceSnapshotStatus::Superseded->value => 'A matching evidence snapshot exists, but it was superseded by newer evidence.', + $status === EvidenceSnapshotStatus::Failed->value => 'A matching evidence snapshot exists, but evidence generation failed.', + $completeness === EvidenceCompletenessState::Stale->value => 'A matching evidence snapshot exists, but its evidence basis is already stale.', + $completeness === EvidenceCompletenessState::Partial->value => 'A matching evidence snapshot exists, but its evidence basis is incomplete.', + default => 'A matching evidence snapshot exists, but it does not provide usable evidence truth yet.', + }; + + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: $reasonMessage, + evidence: $evidence + ['chosen_snapshot_id' => (int) $snapshot->getKey()], + related: $related, + summaryCounts: $this->summaryCounts($snapshot), + ); + } + + public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult + { + return null; + } + + private function isUsableSnapshot(EvidenceSnapshot $snapshot): bool + { + if ((string) $snapshot->status !== EvidenceSnapshotStatus::Active->value) { + return false; + } + + if ((string) $snapshot->completeness_state !== EvidenceCompletenessState::Complete->value) { + return false; + } + + return $snapshot->expires_at === null || $snapshot->expires_at->isFuture(); + } + + /** + * @return array + */ + private function relatedSnapshotMetadata(EvidenceSnapshot $snapshot): array + { + return array_filter([ + 'type' => 'evidence_snapshot', + 'id' => (int) $snapshot->getKey(), + 'status' => (string) $snapshot->status, + 'completeness_state' => (string) $snapshot->completeness_state, + 'fingerprint' => (string) $snapshot->fingerprint, + 'operation_run_id' => is_numeric($snapshot->operation_run_id) ? (int) $snapshot->operation_run_id : null, + 'expires_at' => $snapshot->expires_at?->toIso8601String(), + ], static fn (mixed $value): bool => $value !== null && $value !== []); + } + + /** + * @return array + */ + private function summaryCounts(EvidenceSnapshot $snapshot): array + { + $summary = is_array($snapshot->summary) ? $snapshot->summary : []; + + return array_filter([ + 'finding_count' => is_numeric($summary['finding_count'] ?? null) ? (int) $summary['finding_count'] : null, + 'report_count' => is_numeric($summary['report_count'] ?? null) ? (int) $summary['report_count'] : null, + 'operation_count' => is_numeric($summary['operation_count'] ?? null) ? (int) $summary['operation_count'] : null, + ], static fn (mixed $value): bool => is_int($value)); + } +} diff --git a/apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php b/apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php index 5e91e133..aa09339e 100644 --- a/apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php +++ b/apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php @@ -9,6 +9,8 @@ final class OperationRunReconciliationRegistry public function __construct( private readonly RestoreExecuteReconciliationAdapter $restoreExecuteAdapter, private readonly EnvironmentReviewComposeReconciliationAdapter $environmentReviewComposeAdapter, + private readonly EvidenceSnapshotReconciliationAdapter $evidenceSnapshotAdapter, + private readonly ReviewPackArtifactReconciliationAdapter $reviewPackAdapter, ) {} /** @@ -19,6 +21,8 @@ public function all(): array return [ $this->restoreExecuteAdapter, $this->environmentReviewComposeAdapter, + $this->evidenceSnapshotAdapter, + $this->reviewPackAdapter, ]; } @@ -30,6 +34,8 @@ public function supportedTypes(): array return array_values(array_unique(array_merge( $this->restoreExecuteAdapter->supportedTypes(), $this->environmentReviewComposeAdapter->supportedTypes(), + $this->evidenceSnapshotAdapter->supportedTypes(), + $this->reviewPackAdapter->supportedTypes(), ))); } diff --git a/apps/platform/app/Support/Operations/Reconciliation/ReviewPackArtifactReconciliationAdapter.php b/apps/platform/app/Support/Operations/Reconciliation/ReviewPackArtifactReconciliationAdapter.php new file mode 100644 index 00000000..cc93beb4 --- /dev/null +++ b/apps/platform/app/Support/Operations/Reconciliation/ReviewPackArtifactReconciliationAdapter.php @@ -0,0 +1,314 @@ +supportsType((string) $run->type)) { + return ReconciliationResult::unsupported( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'This adapter only supports review pack generation runs.', + evidence: [ + 'adapter' => $this->key(), + 'type' => (string) $run->type, + ], + ); + } + + $context = is_array($run->context) ? $run->context : []; + $workspaceId = (int) ($run->workspace_id ?? $context['workspace_id'] ?? 0); + $tenantId = (int) ($run->managed_environment_id ?? $context['managed_environment_id'] ?? 0); + $reviewId = is_numeric($context['environment_review_id'] ?? null) ? (int) $context['environment_review_id'] : null; + $snapshotId = is_numeric($context['evidence_snapshot_id'] ?? null) ? (int) $context['evidence_snapshot_id'] : null; + $options = $this->normalizeOptions($context); + + $evidence = [ + 'adapter' => $this->key(), + 'operation_run_id' => (int) $run->getKey(), + 'workspace_id' => $workspaceId, + 'managed_environment_id' => $tenantId, + 'environment_review_id' => $reviewId, + 'evidence_snapshot_id' => $snapshotId, + 'include_pii' => $options['include_pii'], + 'include_operations' => $options['include_operations'], + ]; + + if ($workspaceId <= 0 || $tenantId <= 0 || ($reviewId === null && $snapshotId === null)) { + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'The run no longer carries enough review pack scope to reconcile it safely.', + evidence: $evidence, + ); + } + + $tenant = ManagedEnvironment::query() + ->whereKey($tenantId) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $tenant instanceof ManagedEnvironment) { + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'The run no longer points at a valid workspace-owned environment for review pack reconciliation.', + evidence: $evidence, + ); + } + + $expectedFingerprint = null; + $candidatesQuery = ReviewPack::query() + ->where('workspace_id', $workspaceId) + ->where('managed_environment_id', $tenantId) + ->orderByDesc('generated_at') + ->orderByDesc('id'); + + if ($reviewId !== null) { + $review = EnvironmentReview::query() + ->whereKey($reviewId) + ->where('workspace_id', $workspaceId) + ->where('managed_environment_id', $tenantId) + ->first(); + + if (! $review instanceof EnvironmentReview) { + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'The queued review scope no longer resolves to a current review record safely.', + evidence: $evidence, + ); + } + + $expectedFingerprint = $this->reviewPacks->computeFingerprintForReview($review, $options); + $candidatesQuery->where('environment_review_id', $reviewId); + } else { + $snapshot = EvidenceSnapshot::query() + ->whereKey($snapshotId) + ->where('workspace_id', $workspaceId) + ->where('managed_environment_id', $tenantId) + ->first(); + + if (! $snapshot instanceof EvidenceSnapshot) { + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'The queued evidence basis for this review pack no longer resolves safely.', + evidence: $evidence, + ); + } + + $expectedFingerprint = $this->reviewPacks->computeFingerprintForSnapshot($snapshot, $options); + $candidatesQuery->where('evidence_snapshot_id', (int) $snapshot->getKey()); + } + + $evidence['expected_fingerprint'] = $expectedFingerprint; + + $candidates = $candidatesQuery + ->get() + ->filter(fn (ReviewPack $pack): bool => $this->matchesOptions($pack, $options)) + ->values(); + + $evidence['considered_pack_ids'] = $candidates->modelKeys(); + $evidence['considered_packs'] = $candidates + ->map(fn (ReviewPack $pack): array => $this->reviewPackReference($pack)) + ->values() + ->all(); + + /** @var ReviewPack|null $pack */ + $pack = $candidates->first(fn (ReviewPack $candidate): bool => (string) $candidate->fingerprint === $expectedFingerprint); + + if (! $pack instanceof ReviewPack) { + if ($candidates->isEmpty()) { + return ReconciliationResult::notReconciled( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'No matching review pack is available yet for this run.', + evidence: $evidence, + ); + } + + if ($candidates->count() === 1) { + $candidate = $candidates->first(); + + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'A review pack exists for this scope, but its fingerprint no longer matches the queued run safely.', + evidence: $evidence + ['chosen_review_pack_id' => (int) $candidate->getKey()], + related: $this->relatedReviewPackMetadata($candidate), + summaryCounts: $this->summaryCounts($candidate), + ); + } + + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: 'Multiple review packs match this scope, so the run needs manual review.', + evidence: $evidence, + ); + } + + $related = $this->relatedReviewPackMetadata($pack); + + if ($this->isUsablePack($pack)) { + return ReconciliationResult::reconciledSucceeded( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: (int) ($pack->operation_run_id ?? 0) === (int) $run->getKey() + ? 'The queued review pack was already completed before the run finished updating.' + : 'A matching review pack was already available for this run.', + evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()], + related: $related, + summaryCounts: $this->summaryCounts($pack), + ); + } + + if (in_array((string) $pack->status, [ + ReviewPackStatus::Queued->value, + ReviewPackStatus::Generating->value, + ], true)) { + return ReconciliationResult::notReconciled( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: (int) ($pack->operation_run_id ?? 0) === (int) $run->getKey() + ? 'The queued review pack is still generating and does not prove final output truth yet.' + : 'A matching review pack exists, but it is still generating.', + evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()], + related: $related, + ); + } + + $reasonMessage = match (true) { + (string) $pack->status === ReviewPackStatus::Expired->value => 'A matching review pack exists, but it is already expired.', + (string) $pack->status === ReviewPackStatus::Failed->value => 'A matching review pack exists, but generation failed.', + ! $this->hasShareableFile($pack) => 'A matching review pack exists, but it does not expose a shareable artifact safely yet.', + default => 'A matching review pack exists, but it is not ready for use.', + }; + + return ReconciliationResult::attentionRequired( + reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value, + reasonMessage: $reasonMessage, + evidence: $evidence + ['chosen_review_pack_id' => (int) $pack->getKey()], + related: $related, + summaryCounts: $this->summaryCounts($pack), + ); + } + + public function reconcileException(OperationRun $run, Throwable $throwable): ?ReconciliationResult + { + return null; + } + + /** + * @param array $context + * @return array{include_pii: bool, include_operations: bool} + */ + private function normalizeOptions(array $context): array + { + return [ + 'include_pii' => (bool) ($context['include_pii'] ?? true), + 'include_operations' => (bool) ($context['include_operations'] ?? true), + ]; + } + + /** + * @param array{include_pii: bool, include_operations: bool} $options + */ + private function matchesOptions(ReviewPack $pack, array $options): bool + { + $packOptions = is_array($pack->options) ? $pack->options : []; + + return (bool) ($packOptions['include_pii'] ?? true) === $options['include_pii'] + && (bool) ($packOptions['include_operations'] ?? true) === $options['include_operations']; + } + + private function isUsablePack(ReviewPack $pack): bool + { + if ((string) $pack->status !== ReviewPackStatus::Ready->value) { + return false; + } + + if ($pack->expires_at !== null && $pack->expires_at->isPast()) { + return false; + } + + return $this->hasShareableFile($pack); + } + + private function hasShareableFile(ReviewPack $pack): bool + { + return is_string($pack->file_disk) && trim($pack->file_disk) !== '' + && is_string($pack->file_path) && trim($pack->file_path) !== '' + && is_string($pack->sha256) && trim($pack->sha256) !== ''; + } + + /** + * @return array + */ + private function relatedReviewPackMetadata(ReviewPack $pack): array + { + return array_filter([ + 'type' => 'review_pack', + 'id' => (int) $pack->getKey(), + 'status' => (string) $pack->status, + 'fingerprint' => (string) $pack->fingerprint, + 'operation_run_id' => is_numeric($pack->operation_run_id) ? (int) $pack->operation_run_id : null, + 'environment_review_id' => is_numeric($pack->environment_review_id) ? (int) $pack->environment_review_id : null, + 'evidence_snapshot_id' => is_numeric($pack->evidence_snapshot_id) ? (int) $pack->evidence_snapshot_id : null, + 'expires_at' => $pack->expires_at?->toIso8601String(), + ], static fn (mixed $value): bool => $value !== null && $value !== []); + } + + /** + * @return array + */ + private function reviewPackReference(ReviewPack $pack): array + { + return [ + 'id' => (int) $pack->getKey(), + 'status' => (string) $pack->status, + 'fingerprint' => (string) $pack->fingerprint, + 'operation_run_id' => is_numeric($pack->operation_run_id) ? (int) $pack->operation_run_id : null, + 'environment_review_id' => is_numeric($pack->environment_review_id) ? (int) $pack->environment_review_id : null, + 'evidence_snapshot_id' => is_numeric($pack->evidence_snapshot_id) ? (int) $pack->evidence_snapshot_id : null, + ]; + } + + /** + * @return array + */ + private function summaryCounts(ReviewPack $pack): array + { + $summary = is_array($pack->summary) ? $pack->summary : []; + + return array_filter([ + 'finding_count' => is_numeric($summary['finding_count'] ?? null) ? (int) $summary['finding_count'] : null, + 'report_count' => is_numeric($summary['report_count'] ?? null) ? (int) $summary['report_count'] : null, + 'operation_count' => is_numeric($summary['operation_count'] ?? null) ? (int) $summary['operation_count'] : null, + ], static fn (mixed $value): bool => is_int($value)); + } +} diff --git a/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php b/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php index ded7e7b2..ebcefb9e 100644 --- a/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php +++ b/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php @@ -1131,13 +1131,39 @@ private function resolveArtifactForRun(OperationRun $run): BaselineSnapshot|Evid 'baseline_snapshot' => $run->relatedArtifactId() !== null ? BaselineSnapshot::query()->with('baselineProfile')->find($run->relatedArtifactId()) : null, - 'evidence_snapshot' => EvidenceSnapshot::query()->with('tenant')->where('operation_run_id', (int) $run->getKey())->latest('id')->first(), + 'evidence_snapshot' => $this->resolveEvidenceSnapshotForRun($run), 'environment_review' => $this->resolveEnvironmentReviewForRun($run), - 'review_pack' => ReviewPack::query()->with(['tenant', 'environmentReview'])->where('operation_run_id', (int) $run->getKey())->latest('id')->first(), + 'review_pack' => $this->resolveReviewPackForRun($run), default => null, }; } + private function resolveEvidenceSnapshotForRun(OperationRun $run): ?EvidenceSnapshot + { + $relatedSnapshotId = $run->reconciledRelatedEvidenceSnapshotId(); + + if ($relatedSnapshotId !== null) { + $snapshot = EvidenceSnapshot::query() + ->with('tenant') + ->whereKey($relatedSnapshotId) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->first(); + + if ($snapshot instanceof EvidenceSnapshot) { + return $snapshot; + } + } + + return EvidenceSnapshot::query() + ->with('tenant') + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->where('operation_run_id', (int) $run->getKey()) + ->latest('id') + ->first(); + } + private function resolveEnvironmentReviewForRun(OperationRun $run): ?EnvironmentReview { $relatedReviewId = $run->reconciledRelatedReviewId(); @@ -1157,6 +1183,34 @@ private function resolveEnvironmentReviewForRun(OperationRun $run): ?Environment return EnvironmentReview::query() ->with(['tenant', 'currentExportReviewPack']) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->where('operation_run_id', (int) $run->getKey()) + ->latest('id') + ->first(); + } + + private function resolveReviewPackForRun(OperationRun $run): ?ReviewPack + { + $relatedReviewPackId = $run->reconciledRelatedReviewPackId(); + + if ($relatedReviewPackId !== null) { + $pack = ReviewPack::query() + ->with(['tenant', 'environmentReview']) + ->whereKey($relatedReviewPackId) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) + ->first(); + + if ($pack instanceof ReviewPack) { + return $pack; + } + } + + return ReviewPack::query() + ->with(['tenant', 'environmentReview']) + ->where('workspace_id', (int) $run->workspace_id) + ->where('managed_environment_id', (int) $run->managed_environment_id) ->where('operation_run_id', (int) $run->getKey()) ->latest('id') ->first(); diff --git a/apps/platform/tests/Browser/Spec361ArtifactReconciliationSmokeTest.php b/apps/platform/tests/Browser/Spec361ArtifactReconciliationSmokeTest.php new file mode 100644 index 00000000..5b950b71 --- /dev/null +++ b/apps/platform/tests/Browser/Spec361ArtifactReconciliationSmokeTest.php @@ -0,0 +1,230 @@ +browser()->timeout(60_000); + +it('Spec361 smokes canonical evidence snapshot reconciliation drill-through on the existing operations surfaces', function (): void { + [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + + spec361AuthenticateBrowser($this, $user, $environment); + + $snapshot = restateEnvironmentReviewEvidenceSnapshot( + seedEnvironmentReviewEvidence($environment, operationRunCount: 2), + EvidenceCompletenessState::Complete, + ); + $run = spec361BrowserCreateCanonicalReconciledEvidenceRun($environment, $user, $snapshot); + + visit(OperationRunLinks::index($environment)) + ->resize(1440, 1100) + ->waitForText('Operations Hub') + ->assertSee('Automatically reconciled') + ->assertSee('No action needed.') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + visit(OperationRunLinks::tenantlessView($run)) + ->waitForText('Monitoring detail') + ->assertSee('Automatically reconciled') + ->click('Open') + ->assertSee('View evidence snapshot') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); + +it('Spec361 smokes canonical review-pack reconciliation drill-through on the existing operations surfaces', function (): void { + [$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager'); + + spec361AuthenticateBrowser($this, $user, $environment); + + [$pack, $run] = spec361BrowserCreateCanonicalReconciledReviewPackRun($environment, $user); + + visit(OperationRunLinks::index($environment)) + ->resize(1440, 1100) + ->waitForText('Operations Hub') + ->assertSee('Automatically reconciled') + ->assertSee('Review the source review before sharing this pack.') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); + + visit(OperationRunLinks::tenantlessView($run)) + ->waitForText('Monitoring detail') + ->assertSee('Automatically reconciled') + ->click('Open') + ->assertSee('View review pack') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); + +function spec361AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void +{ + $workspaceId = (int) $environment->workspace_id; + + $test->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => $workspaceId, + WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [ + (string) $workspaceId => (int) $environment->getKey(), + ], + ]); + + session()->put(WorkspaceContext::SESSION_KEY, $workspaceId); + session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [ + (string) $workspaceId => (int) $environment->getKey(), + ]); + + setAdminPanelContext($environment); +} + +function spec361BrowserCreateCanonicalReconciledEvidenceRun( + ManagedEnvironment $environment, + User $user, + mixed $snapshot, +): OperationRun { + $run = OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(30), + 'context' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'fingerprint' => (string) $snapshot->fingerprint, + ], + ]); + + return app(OperationRunService::class)->updateRunWithReconciliation( + run: $run, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: [ + 'finding_count' => (int) data_get($snapshot->summary, 'finding_count', 0), + 'report_count' => (int) data_get($snapshot->summary, 'report_count', 0), + 'operation_count' => (int) data_get($snapshot->summary, 'operation_count', 0), + ], + failures: [], + reasonCode: 'run.adapter_out_of_sync', + reasonMessage: 'A matching evidence snapshot was already available for this run.', + source: 'adapter_reconciler', + evidence: [ + 'fingerprint' => (string) $snapshot->fingerprint, + ], + adapter: 'evidence_snapshot', + decision: 'reconciled_succeeded', + related: [ + 'type' => 'evidence_snapshot', + 'id' => (int) $snapshot->getKey(), + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + ], + )->fresh(); +} + +/** + * @return array{0: ReviewPack, 1: OperationRun} + */ +function spec361BrowserCreateCanonicalReconciledReviewPackRun( + ManagedEnvironment $environment, + User $user, +): array { + $snapshot = restateEnvironmentReviewEvidenceSnapshot( + seedEnvironmentReviewEvidence($environment, operationRunCount: 2), + EvidenceCompletenessState::Complete, + ); + $review = composeEnvironmentReviewForTest($environment, $user, $snapshot); + $options = [ + 'include_pii' => false, + 'include_operations' => true, + ]; + $fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options); + + $publishedRun = OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + 'completed_at' => now()->subMinutes(5), + ]); + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $environment->getKey(), + 'workspace_id' => (int) $environment->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'operation_run_id' => (int) $publishedRun->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => $fingerprint, + 'options' => $options, + 'status' => ReviewPackStatus::Ready->value, + 'summary' => [ + 'finding_count' => 4, + 'report_count' => 2, + 'operation_count' => 3, + ], + ]); + + $run = OperationRun::factory()->forTenant($environment)->create([ + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(30), + 'context' => [ + 'workspace_id' => (int) $environment->workspace_id, + 'managed_environment_id' => (int) $environment->getKey(), + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'include_pii' => $options['include_pii'], + 'include_operations' => $options['include_operations'], + ], + ]); + + $run = app(OperationRunService::class)->updateRunWithReconciliation( + run: $run, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: [ + 'finding_count' => 4, + 'report_count' => 2, + 'operation_count' => 3, + ], + failures: [], + reasonCode: 'run.adapter_out_of_sync', + reasonMessage: 'A matching review pack was already available for this run.', + source: 'adapter_reconciler', + evidence: [ + 'fingerprint' => $fingerprint, + ], + adapter: 'review_pack', + decision: 'reconciled_succeeded', + related: [ + 'type' => 'review_pack', + 'id' => (int) $pack->getKey(), + 'status' => ReviewPackStatus::Ready->value, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + ], + )->fresh(); + + return [$pack, $run]; +} diff --git a/apps/platform/tests/Feature/Operations/Spec361EvidenceSnapshotReconciliationTest.php b/apps/platform/tests/Feature/Operations/Spec361EvidenceSnapshotReconciliationTest.php new file mode 100644 index 00000000..56693f6b --- /dev/null +++ b/apps/platform/tests/Feature/Operations/Spec361EvidenceSnapshotReconciliationTest.php @@ -0,0 +1,215 @@ +operation_run_id; + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'fingerprint' => (string) $snapshot->fingerprint, + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false); + + expect($change['applied'] ?? null)->toBeTrue(); + + $run->refresh(); + $snapshot->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) + ->and($run->reconciliationDecision())->toBe('reconciled_succeeded') + ->and($run->reconciliationAdapter())->toBe('evidence_snapshot') + ->and($run->reconciledRelatedEvidenceSnapshotId())->toBe((int) $snapshot->getKey()) + ->and($run->summary_counts)->toMatchArray([ + 'finding_count' => (int) data_get($snapshot->summary, 'finding_count', 0), + 'report_count' => (int) data_get($snapshot->summary, 'report_count', 0), + 'operation_count' => (int) data_get($snapshot->summary, 'operation_count', 0), + ]) + ->and($snapshot->status)->toBe(EvidenceSnapshotStatus::Active->value) + ->and($snapshot->completeness_state)->toBe(EvidenceCompletenessState::Complete->value) + ->and($snapshot->operation_run_id)->toBe($originalOperationRunId); + + $this->actingAs($user); + setAdminPanelContext($tenant); + + $expected = EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant); + $links = OperationRunLinks::related($run->fresh(), $tenant); + $sharedLinks = app(RelatedNavigationResolver::class)->operationLinks($run->fresh(), $tenant); + $sharedEntry = collect(app(RelatedNavigationResolver::class)->detailEntries( + CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, + $run->fresh(), + ))->firstWhere('key', 'evidence_snapshot'); + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh()); + + expect($links['Evidence Snapshot'] ?? null)->toBe($expected) + ->and($sharedLinks['View evidence snapshot'] ?? null)->toBe($expected) + ->and($sharedEntry['targetUrl'] ?? null)->toBe($expected) + ->and($sharedEntry['actionLabel'] ?? null)->toBe('View evidence snapshot') + ->and($truth?->relatedArtifactUrl)->toBe($expected); +}); + +it('marks evidence-generation runs attention-required when only partial snapshot truth exists in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $snapshot = seedPartialEnvironmentReviewEvidence($tenant); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'fingerprint' => (string) $snapshot->fingerprint, + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false); + + expect($change['applied'] ?? null)->toBeTrue(); + + $run->refresh(); + $snapshot->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Failed->value) + ->and($run->reconciliationDecision())->toBe('attention_required') + ->and((string) data_get($run->failure_summary, '0.message'))->toContain('evidence basis is incomplete') + ->and($snapshot->status)->toBe(EvidenceSnapshotStatus::Active->value) + ->and($snapshot->completeness_state)->toBe(EvidenceCompletenessState::Partial->value); +}); + +it('keeps evidence-generation runs queued when the matching snapshot is still generating in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $fingerprint = 'spec361-evidence-generating'; + + EvidenceSnapshot::query()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'initiated_by_user_id' => (int) $user->getKey(), + 'status' => EvidenceSnapshotStatus::Generating->value, + 'fingerprint' => $fingerprint, + 'completeness_state' => EvidenceCompletenessState::Missing->value, + 'summary' => [ + 'finding_count' => 0, + 'report_count' => 0, + 'operation_count' => 0, + ], + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'fingerprint' => $fingerprint, + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true); + + expect($change['applied'] ?? null)->toBeFalse() + ->and($change['decision'] ?? null)->toBe('not_reconciled') + ->and((string) ($change['reason_message'] ?? ''))->toContain('still generating'); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value); +}); + +it('does not cross-scope reconcile evidence-generation runs in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $foreignTenant = ManagedEnvironment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + $fingerprint = 'spec361-evidence-cross-scope'; + + EvidenceSnapshot::query()->create([ + 'managed_environment_id' => (int) $foreignTenant->getKey(), + 'workspace_id' => (int) $foreignTenant->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'fingerprint' => $fingerprint, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => [ + 'finding_count' => 1, + 'report_count' => 1, + 'operation_count' => 1, + ], + 'generated_at' => now(), + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'fingerprint' => $fingerprint, + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true); + + expect($change['applied'] ?? null)->toBeFalse() + ->and($change['decision'] ?? null)->toBe('not_reconciled') + ->and((string) ($change['reason_message'] ?? ''))->toContain('No matching evidence snapshot'); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value) + ->and($run->reconciledRelatedEvidenceSnapshotId())->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Operations/Spec361ReviewPackReconciliationTest.php b/apps/platform/tests/Feature/Operations/Spec361ReviewPackReconciliationTest.php new file mode 100644 index 00000000..e3ce1a71 --- /dev/null +++ b/apps/platform/tests/Feature/Operations/Spec361ReviewPackReconciliationTest.php @@ -0,0 +1,269 @@ + false, + 'include_operations' => true, + ]; + $fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options); + + $publishedRun = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + ]); + + $pack = ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'operation_run_id' => (int) $publishedRun->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => $fingerprint, + 'options' => $options, + 'summary' => [ + 'finding_count' => 4, + 'report_count' => 2, + 'operation_count' => 3, + ], + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'include_pii' => $options['include_pii'], + 'include_operations' => $options['include_operations'], + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false); + + expect($change['applied'] ?? null)->toBeTrue(); + + $run->refresh(); + $pack->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value) + ->and($run->reconciliationDecision())->toBe('reconciled_succeeded') + ->and($run->reconciliationAdapter())->toBe('review_pack') + ->and($run->reconciledRelatedReviewPackId())->toBe((int) $pack->getKey()) + ->and($run->summary_counts)->toMatchArray([ + 'finding_count' => 4, + 'report_count' => 2, + 'operation_count' => 3, + ]) + ->and($pack->status)->toBe(ReviewPackStatus::Ready->value) + ->and($pack->fingerprint)->toBe($fingerprint); + + $this->actingAs($user); + setAdminPanelContext($tenant); + + $expected = ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant); + $links = OperationRunLinks::related($run->fresh(), $tenant); + $sharedLinks = app(RelatedNavigationResolver::class)->operationLinks($run->fresh(), $tenant); + $sharedEntry = collect(app(RelatedNavigationResolver::class)->detailEntries( + CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, + $run->fresh(), + ))->firstWhere('key', 'review_pack'); + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh()); + + expect($links['Review Pack'] ?? null)->toBe($expected) + ->and($sharedLinks['View review pack'] ?? null)->toBe($expected) + ->and($sharedEntry['targetUrl'] ?? null)->toBe($expected) + ->and($sharedEntry['actionLabel'] ?? null)->toBe('View review pack') + ->and($truth?->relatedArtifactUrl)->toBe($expected); +}); + +it('keeps review-pack runs queued when the matching pack is still generating in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $snapshot = seedEnvironmentReviewEvidence($tenant); + $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); + $options = [ + 'include_pii' => true, + 'include_operations' => false, + ]; + $fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options); + + ReviewPack::factory()->generating()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => $fingerprint, + 'options' => $options, + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'include_pii' => $options['include_pii'], + 'include_operations' => $options['include_operations'], + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true); + + expect($change['applied'] ?? null)->toBeFalse() + ->and($change['decision'] ?? null)->toBe('not_reconciled') + ->and((string) ($change['reason_message'] ?? ''))->toContain('still generating'); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value); +}); + +it('marks review-pack runs attention-required when the matching pack is expired in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $snapshot = seedEnvironmentReviewEvidence($tenant); + $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); + $options = [ + 'include_pii' => true, + 'include_operations' => true, + ]; + $fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options); + + ReviewPack::factory()->expired()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => $fingerprint, + 'options' => $options, + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'include_pii' => $options['include_pii'], + 'include_operations' => $options['include_operations'], + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, false); + + expect($change['applied'] ?? null)->toBeTrue(); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Completed->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Failed->value) + ->and($run->reconciliationDecision())->toBe('attention_required') + ->and((string) data_get($run->failure_summary, '0.message'))->toContain('already expired'); +}); + +it('does not cross-scope reconcile review-pack runs in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $foreignTenant = ManagedEnvironment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + $snapshot = seedEnvironmentReviewEvidence($tenant); + $review = composeEnvironmentReviewForTest($tenant, $user, $snapshot); + $options = [ + 'include_pii' => false, + 'include_operations' => false, + ]; + $fingerprint = app(ReviewPackService::class)->computeFingerprintForReview($review, $options); + + ReviewPack::factory()->ready()->create([ + 'managed_environment_id' => (int) $foreignTenant->getKey(), + 'workspace_id' => (int) $foreignTenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'initiated_by_user_id' => (int) $user->getKey(), + 'fingerprint' => $fingerprint, + 'options' => $options, + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'environment.review_pack.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + 'context' => [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'environment_review_id' => (int) $review->getKey(), + 'evidence_snapshot_id' => (int) $snapshot->getKey(), + 'include_pii' => $options['include_pii'], + 'include_operations' => $options['include_operations'], + ], + ]); + + $change = app(AdapterRunReconciler::class)->reconcileOperationRun($run, true); + + expect($change['applied'] ?? null)->toBeFalse() + ->and($change['decision'] ?? null)->toBe('not_reconciled') + ->and((string) ($change['reason_message'] ?? ''))->toContain('No matching review pack'); + + $run->refresh(); + + expect($run->status)->toBe(OperationRunStatus::Queued->value) + ->and($run->outcome)->toBe(OperationRunOutcome::Pending->value) + ->and($run->reconciledRelatedReviewPackId())->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Operations/Spec361UnsupportedStoredReportReconciliationTest.php b/apps/platform/tests/Feature/Operations/Spec361UnsupportedStoredReportReconciliationTest.php new file mode 100644 index 00000000..0d263720 --- /dev/null +++ b/apps/platform/tests/Feature/Operations/Spec361UnsupportedStoredReportReconciliationTest.php @@ -0,0 +1,88 @@ +create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, + ]); + StoredReport::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES, + ]); + + $permissionRun = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'permission.posture.check', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + ]); + + $entraRun = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'entra.admin_roles.scan', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + 'created_at' => now()->subMinutes(25), + ]); + + $result = app(AdapterRunReconciler::class)->reconcile([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'older_than_minutes' => 10, + 'limit' => 20, + 'dry_run' => false, + ]); + + expect($result['candidates'] ?? null)->toBe(0) + ->and($result['reconciled'] ?? null)->toBe(0) + ->and($permissionRun->fresh()?->status)->toBe(OperationRunStatus::Queued->value) + ->and($permissionRun->fresh()?->outcome)->toBe(OperationRunOutcome::Pending->value) + ->and($entraRun->fresh()?->status)->toBe(OperationRunStatus::Queued->value) + ->and($entraRun->fresh()?->outcome)->toBe(OperationRunOutcome::Pending->value); +}); + +it('does not surface stored-report artifact links for unsupported report-family runs in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + StoredReport::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE, + ]); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'permission.posture.check', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Failed->value, + ]); + + $links = OperationRunLinks::related($run, $tenant); + $truth = app(ArtifactTruthPresenter::class)->forOperationRun($run); + + expect(array_keys($links))->toBe(['Operations']) + ->and($truth?->relatedArtifactUrl)->toBeNull(); +}); diff --git a/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec359AdapterRunReconcilerSupportedTypesTest.php b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec359AdapterRunReconcilerSupportedTypesTest.php index c776ff25..f828c728 100644 --- a/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec359AdapterRunReconcilerSupportedTypesTest.php +++ b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec359AdapterRunReconcilerSupportedTypesTest.php @@ -5,10 +5,8 @@ use App\Services\AdapterRunReconciler; it('supports restore.execute and environment.review.compose for Spec359', function (): void { - expect(app(AdapterRunReconciler::class)->supportedTypes())->toBe([ - 'restore.execute', - 'environment.review.compose', - ]); + expect(app(AdapterRunReconciler::class)->supportedTypes()) + ->toContain('restore.execute', 'environment.review.compose'); }); it('rejects unsupported adapter filters for Spec359', function (): void { diff --git a/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php index 32c6a6ac..7b18b400 100644 --- a/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php +++ b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec360CanonicalAdapterRegistryTest.php @@ -10,7 +10,11 @@ expect($registry->supportedTypes())->toBe([ 'restore.execute', 'environment.review.compose', + 'tenant.evidence.snapshot.generate', + 'environment.review_pack.generate', ])->and($registry->forType('restore.execute')?->key())->toBe('restore_run') ->and($registry->forType('environment.review.compose')?->key())->toBe('environment_review_compose') + ->and($registry->forType('tenant.evidence.snapshot.generate')?->key())->toBe('evidence_snapshot') + ->and($registry->forType('environment.review_pack.generate')?->key())->toBe('review_pack') ->and($registry->forType('policy.sync'))->toBeNull(); }); diff --git a/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactProofRulesTest.php b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactProofRulesTest.php new file mode 100644 index 00000000..cdbe9449 --- /dev/null +++ b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactProofRulesTest.php @@ -0,0 +1,108 @@ +create([ + 'context' => [ + 'reconciliation' => [ + 'related' => [ + 'type' => 'review_pack', + 'id' => 71, + 'status' => 'ready', + 'review' => [ + 'id' => 99, + ], + ], + ], + ], + ]); + + $evidenceRun = OperationRun::factory()->create([ + 'context' => [ + 'reconciliation' => [ + 'related' => [ + 'type' => 'evidence_snapshot', + 'id' => 37, + 'status' => 'active', + 'review' => [ + 'id' => 88, + ], + ], + ], + ], + ]); + + expect($reviewPackRun->reconciledRelatedType())->toBe('review_pack') + ->and($reviewPackRun->reconciledRelatedReviewPackId())->toBe(71) + ->and($reviewPackRun->reconciledRelatedReviewId())->toBeNull() + ->and($evidenceRun->reconciledRelatedType())->toBe('evidence_snapshot') + ->and($evidenceRun->reconciledRelatedEvidenceSnapshotId())->toBe(37) + ->and($evidenceRun->reconciledRelatedReviewId())->toBeNull(); +}); + +it('retains the legacy nested review fallback only for environment reviews in Spec361', function (): void { + $run = OperationRun::factory()->create([ + 'context' => [ + 'reconciliation' => [ + 'related' => [ + 'review' => [ + 'id' => 44, + 'status' => 'published', + ], + ], + ], + ], + ]); + + expect($run->reconciledRelatedType())->toBeNull() + ->and($run->reconciledRelatedReviewId())->toBe(44) + ->and($run->reconciledRelatedEvidenceSnapshotId())->toBeNull() + ->and($run->reconciledRelatedReviewPackId())->toBeNull(); +}); + +it('writes canonical artifact related metadata through OperationRunService in Spec361', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $run = OperationRun::factory()->create([ + 'managed_environment_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'user_id' => (int) $user->getKey(), + 'type' => 'tenant.evidence.snapshot.generate', + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + app(OperationRunService::class)->updateRunWithReconciliation( + run: $run, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + summaryCounts: ['finding_count' => 3], + failures: [], + reasonCode: 'run.adapter_out_of_sync', + reasonMessage: 'A matching evidence snapshot was already available for this run.', + source: 'adapter_reconciler', + evidence: ['fingerprint' => 'spec361-evidence'], + adapter: 'evidence_snapshot', + decision: 'reconciled_succeeded', + related: [ + 'type' => 'evidence_snapshot', + 'id' => 42, + 'status' => 'active', + 'completeness_state' => 'complete', + ], + ); + + expect(data_get($run->fresh()->context, 'reconciliation.related.type'))->toBe('evidence_snapshot') + ->and(data_get($run->fresh()->context, 'reconciliation.related.id'))->toBe(42) + ->and(data_get($run->fresh()->context, 'reconciliation.related.status'))->toBe('active') + ->and(data_get($run->fresh()->context, 'reconciliation.related.completeness_state'))->toBe('complete'); +}); diff --git a/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactRegistryResolutionTest.php b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactRegistryResolutionTest.php new file mode 100644 index 00000000..9ad41321 --- /dev/null +++ b/apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactRegistryResolutionTest.php @@ -0,0 +1,19 @@ +supportedTypes())->toBe([ + 'restore.execute', + 'environment.review.compose', + 'tenant.evidence.snapshot.generate', + 'environment.review_pack.generate', + ])->and($registry->forType('tenant.evidence.snapshot.generate')?->key())->toBe('evidence_snapshot') + ->and($registry->forType('environment.review_pack.generate')?->key())->toBe('review_pack') + ->and($registry->forType('permission.posture.check'))->toBeNull() + ->and($registry->forType('entra.admin_roles.scan'))->toBeNull(); +}); diff --git a/apps/platform/tests/Unit/Support/RelatedActionLabelCatalogTest.php b/apps/platform/tests/Unit/Support/RelatedActionLabelCatalogTest.php index bb1c346d..dd29cf90 100644 --- a/apps/platform/tests/Unit/Support/RelatedActionLabelCatalogTest.php +++ b/apps/platform/tests/Unit/Support/RelatedActionLabelCatalogTest.php @@ -11,6 +11,10 @@ ->and($catalog->actionLabel('baseline_snapshot'))->toBe('View snapshot') ->and($catalog->entryLabel('environment_review'))->toBe('ManagedEnvironment Review') ->and($catalog->actionLabel('environment_review'))->toBe('ManagedEnvironment Review') + ->and($catalog->entryLabel('evidence_snapshot'))->toBe('Evidence snapshot') + ->and($catalog->actionLabel('evidence_snapshot'))->toBe('View evidence snapshot') + ->and($catalog->entryLabel('review_pack'))->toBe('Review pack') + ->and($catalog->actionLabel('review_pack'))->toBe('View review pack') ->and($catalog->entryLabel('source_run'))->toBe('Operation') ->and($catalog->actionLabel('operations'))->toBe('Open operations'); }); diff --git a/specs/361-report-evidence-reconciliation/checklists/requirements.md b/specs/361-report-evidence-reconciliation/checklists/requirements.md new file mode 100644 index 00000000..89ba79b3 --- /dev/null +++ b/specs/361-report-evidence-reconciliation/checklists/requirements.md @@ -0,0 +1,64 @@ +# Requirements Checklist: Spec 361 - Report and Evidence Reconciliation Adapters + +**Purpose**: Preparation analysis for Spec 361 readiness +**Created**: 2026-06-06 +**Feature**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/361-report-evidence-reconciliation/spec.md` + +## Candidate Selection And Guardrails + +- [x] CHK001 The candidate source is explicit: direct user-provided Spec 361 draft from the attached `pasted-text.txt`. +- [x] CHK002 No `specs/361-*` package existed before Spec Kit branch creation. +- [x] CHK003 The active candidate queue's `no safe automatic next-best-prep target` note is respected; this package is an intentional manual promotion, not an auto-selected queue item. +- [x] CHK004 Related specs were checked and treated correctly: Spec 358 is queue-truth context, Spec 359 is merged adapter baseline, Spec 360 is canonical-cutover context, and Specs 347/349/351/356/357 are review-pack or rendered-report context only. +- [x] CHK005 Repo-truth deviations from the user draft are recorded in `spec.md`, especially the narrowed safe scope around `EvidenceSnapshot` and `ReviewPack` plus the explicit defer/fail-closed treatment for generic `StoredReport` families. + +## Required Prep Artifacts + +- [x] CHK006 `spec.md` exists and contains no template placeholders. +- [x] CHK007 `plan.md` exists and is repo-aware. +- [x] CHK008 `tasks.md` exists and is ordered, small, and verifiable. +- [x] CHK009 This checklist exists. + +## Spec Quality + +- [x] CHK010 Spec Candidate Check is completed and scores above the approval threshold. +- [x] CHK011 The spec keeps `OperationRun`, `EvidenceSnapshot`, `ReviewPack`, and `StoredReport` persistence unchanged. +- [x] CHK012 The spec explains why bounded adapter extension is justified now under ABSTR-001: the repo already has real adapter-backed reconciliation consumers. +- [x] CHK013 The spec keeps scope bounded to evidence reconciliation, review-pack reconciliation, and honest unsupported generic report-family handling. +- [x] CHK014 The proportionality review rejects a generic report-artifact engine, new operation types, new persistence, and weak "latest artifact exists" heuristics. + +## Plan / Task Alignment + +- [x] CHK015 The plan identifies the actual repo surfaces likely to change, including the current registry, write seam, artifact models, artifact-truth presenters, and operations/detail pages. +- [x] CHK016 The plan keeps Filament v5 / Livewire v4 posture and provider-registration location visible. +- [x] CHK017 The plan explicitly states that no migration, no new panel/provider, no new global search change, and no new asset strategy are expected. +- [x] CHK018 The tasks start with repo truth and failing tests before runtime edits. +- [x] CHK019 The tasks include explicit anti-creep guardrails against new persistence, new operation types, generic stored-report heuristics, and rendered-report scope creep. + +## UI / Monitoring / Artifact Coverage + +- [x] CHK020 UI Surface Impact is completed and does not claim a new page family. +- [x] CHK021 The changed surfaces are correctly classified as existing operations monitoring/detail plus existing artifact detail follow-through, not a new strategic customer-facing page. +- [x] CHK022 No new page-report identity or route-inventory expansion is required unless implementation proves a materially new visible hierarchy. +- [x] CHK023 Audience-aware disclosure and no-overclaim wording boundaries are explicit for both operations and artifact detail surfaces. + +## Test Governance + +- [x] CHK024 The declared test families are the narrowest honest proof: Unit + Feature + one bounded Browser smoke. +- [x] CHK025 No heavy-governance family or PGSQL-only schema lane is introduced by default. +- [x] CHK026 Planned validation commands are explicit, partitioned into primary merge gate and contextual artifact regressions, and remain scoped to Spec 361 plus directly adjacent evidence/review-pack coverage. + +## Readiness Gate Outcome + +- [x] CHK027 Candidate Selection Gate passes. +- [x] CHK028 Spec Readiness Gate passes. +- [x] CHK029 Runtime implementation has not started in this preparation step. +- [x] CHK030 Recommended next step is implementation, not more prep. + +## Review Outcome + +- [x] Outcome class: acceptable-special-case +- [x] Workflow outcome: keep +- [x] Final note location: active feature PR close-out entry `Guardrail / Smoke Coverage` +- [x] Preparation analyze result: pass via repo-based cross-artifact review; no standalone local `speckit.tasks` or `speckit.analyze` generator command was exposed in this repo surface beyond prompts and agent instructions +- [x] Tooling note: Spec Kit branch/spec creation succeeded via `create-new-feature.sh`, `setup-plan.sh` generated the plan file, and `tasks.md` plus this checklist were authored manually to match the repo's Spec Kit templates and agent instructions diff --git a/specs/361-report-evidence-reconciliation/plan.md b/specs/361-report-evidence-reconciliation/plan.md new file mode 100644 index 00000000..7a08a535 --- /dev/null +++ b/specs/361-report-evidence-reconciliation/plan.md @@ -0,0 +1,223 @@ +# Implementation Plan: Spec 361 - Report and Evidence Reconciliation Adapters + +**Branch**: `361-report-evidence-reconciliation` | **Date**: 2026-06-06 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/361-report-evidence-reconciliation/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/361-report-evidence-reconciliation/spec.md` + +**Note**: This plan is repo-aware and preparation-only. No application implementation is performed in this step. + +## Summary + +Extend the current `OperationRun` reconciliation registry with artifact-backed, read-only adapters for `EvidenceSnapshot` and `ReviewPack`, so queued/running/stale runs can finalize only when current repo-real artifact truth already proves success. Keep generic `StoredReport` families fail-closed and explicitly deferred; Spec 361 does not allow any `StoredReport`-backed success path. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12.52, Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1 +**Storage**: PostgreSQL 16 (`operation_runs`, `evidence_snapshots`, `review_packs`, `stored_reports`) +**Testing**: Pest Unit + Feature + one bounded Browser smoke +**Validation Lanes**: fast-feedback, confidence, browser +**Target Platform**: Laravel monolith in Sail / Dokploy container workflow +**Project Type**: single web application (`apps/platform`) +**Performance Goals**: no new polling or background family; reconciliation stays bounded to current stale/queued scan paths +**Constraints**: no new schema, no new operation type, no new panel/provider, no new Filament asset strategy, no generic stored-report heuristics +**Scale/Scope**: narrow `OperationRun` artifact reconciliation extension over two existing artifact families plus explicit unsupported-family handling + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**: + - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` + - `apps/platform/app/Filament/Resources/ReviewPackResource.php` +- **No-impact class, if applicable**: N/A +- **Native vs custom classification summary**: native Filament surfaces with shared `OperationRun` and artifact-truth helpers +- **Shared-family relevance**: `OperationRun` monitoring family plus artifact-truth detail family +- **State layers in scope**: page, detail, URL-query +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: decision-first on operations surfaces, diagnostics-second on run detail, artifact proof on demand through existing detail pages +- **Raw/support gating plan**: raw context stays in existing diagnostic sections only +- **One-primary-action / duplicate-truth control**: keep current inspect/open paths; do not add a second success summary on artifact pages +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory for any attempt to widen scope into generic stored-report success or new artifact lifecycle semantics +- **Special surface test profiles**: monitoring-state-page +- **Required tests or manual smoke**: functional-core plus one bounded browser smoke +- **Exception path and spread control**: none +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **UI/Productization coverage decision**: no new coverage artifact required; existing monitoring and artifact page families remain sufficient +- **Coverage artifacts to update**: none by default +- **No-impact rationale**: existing reachable surfaces only; no new route family or navigation entry +- **Navigation / Filament provider-panel handling**: no panel or navigation change +- **Screenshot or page-report need**: no by default; use bounded browser smoke unless implementation proves a material hierarchy shift + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: + - `App\Services\AdapterRunReconciler` + - `App\Services\OperationRunService` + - `App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry` + - `App\Support\OperationRunLinks` + - `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter` + - `App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder` +- **Shared abstractions reused**: current adapter contract, current service-owned reconciliation metadata, current monitoring/detail presenters, current artifact-truth presenters and links +- **New abstraction introduced? why?**: at most one small proof/helper path if duplicated scope/completeness checks across Evidence Snapshot and Review Pack become noisy; no generic report framework is allowed +- **Why the existing abstraction was sufficient or insufficient**: the write seam and presenters already exist, but the registry lacks artifact-backed success for evidence and review-pack runs +- **Bounded deviation / spread control**: keep proof logic local to `App\Support\Operations\Reconciliation\`; do not add new persistence or a generalized artifact orchestration engine + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: current `OperationRunService`, registry-backed reconciliation, `OperationRunLinks`, and existing monitoring/detail presenters +- **Delegated UX behaviors**: existing queued toast, run link, artifact link, run-enqueued browser event, and terminal notification paths remain unchanged +- **Surface-owned behavior kept local**: wording and placement only +- **Queued DB-notification policy**: unchanged +- **Terminal notification path**: unchanged central lifecycle mechanism +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: no +- **Provider-owned seams**: N/A +- **Platform-core seams**: `OperationRun`, artifact truth, current scope-safe links +- **Neutral platform terms / contracts preserved**: `operation`, `artifact`, `review pack`, `evidence snapshot`, `reconciliation` +- **Retained provider-specific semantics and why**: none +- **Bounded extraction or follow-up path**: generic `StoredReport` causality remains a separate future follow-up if ever needed + +## Constitution Check + +*GATE: Must pass before implementation starts. Re-check if scope changes.* + +- Inventory-first: PASS. The slice reads current artifact truth; it does not create new snapshot truth. +- Read/write separation: PASS. Adapters read artifacts only and finalize runs only through `OperationRunService`. +- Graph contract path: PASS. No Graph surface change is planned. +- Deterministic capabilities: PASS. No new capability derivation is introduced. +- Workspace and tenant isolation: PASS. Adapters must use run-owned workspace/environment scope and existing resource policies. +- Run observability: PASS. `OperationRun` remains the only lifecycle owner; no shadow truth is introduced. +- TEST-GOV-001: PASS. Unit + Feature + bounded Browser are the narrowest honest proof. +- PROP-001 / ABSTR-001: PASS only if any new helper stays bounded to the two in-scope artifact families and does not become a generic report framework. +- PERSIST-001 / STATE-001: PASS. No new persisted truth or lifecycle family is planned. +- XCUT-001 / LAYER-001: PASS. Extend current registry and presenters; do not create parallel operator language. +- UI-SEM-001 / UI-FIL-001 / UI-COV-001: PASS. Existing native surfaces only; no new page family or asset strategy. +- BADGE-001: PASS. Existing badge and artifact-truth semantics remain authoritative. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit for adapter resolution, proof logic, reconciliation-metadata shape, and idempotency; Feature for run finalization, scope-safe links, and disclosure-layer behavior; Browser for existing Operations or artifact surface wording if it materially changes +- **Affected validation lanes**: fast-feedback, confidence, browser +- **Why this lane mix is the narrowest sufficient proof**: the feature is primarily service and model coordination with a small amount of operator-visible copy and link fallout; no PGSQL-only schema behavior is introduced +- **Narrowest proving command(s)**: + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations/Reconciliation tests/Feature/Operations/Spec361*` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec361ArtifactReconciliationSmokeTest.php` +- **Fixture / helper / factory / seed / context cost risks**: existing workspace/environment and artifact factories only; no new global defaults should be introduced +- **Expensive defaults or shared helper growth introduced?**: no +- **Heavy-family additions, promotions, or visibility changes**: one explicit browser smoke only +- **Surface-class relief / special coverage rule**: monitoring-state-page +- **Closing validation and reviewer handoff**: reviewers should verify that `StoredReport` families stay unsupported in Spec 361, that adapters never mutate artifacts, that `context.reconciliation` stays canonical and idempotent, and that no new operation type or asset strategy slips into scope +- **Budget / baseline / trend follow-up**: none expected +- **Review-stop questions**: does the feature overclaim generic report success, add persistence, or invent a new artifact lifecycle model? +- **Escalation path**: document-in-feature if stored-report proof remains insufficient; reject-or-split if implementation tries to widen scope +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Why no dedicated follow-up spec is needed**: Evidence Snapshot and Review Pack already have strong repo-real proof surfaces. Only generic stored-report causality is deferred explicitly to a future follow-up spec if the repo ever gains a direct proof contract. + +## Repo-Verified Runtime Surfaces Likely Affected + +- `apps/platform/app/Services/AdapterRunReconciler.php` +- `apps/platform/app/Services/OperationRunService.php` +- `apps/platform/app/Models/OperationRun.php` +- `apps/platform/app/Models/EvidenceSnapshot.php` +- `apps/platform/app/Models/ReviewPack.php` +- `apps/platform/app/Models/StoredReport.php` (context only unless proof is stronger than expected) +- `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php` +- `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationAdapter.php` +- `apps/platform/app/Support/OperationRunLinks.php` +- `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` +- `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` +- `apps/platform/app/Support/OperationCatalog.php` +- `apps/platform/app/Support/OperationRunType.php` +- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` +- `apps/platform/app/Services/ReviewPackService.php` +- `apps/platform/app/Filament/Pages/Monitoring/Operations.php` +- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` +- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` +- `apps/platform/app/Filament/Resources/ReviewPackResource.php` +- `apps/platform/tests/Feature/Operations/*` +- `apps/platform/tests/Feature/Evidence/*` +- `apps/platform/tests/Feature/ReviewPack/*` +- `apps/platform/tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php` and new Spec 361 smoke coverage as needed + +## Technical Approach + +1. Verify the exact current repo-real operation types, artifact models, and status/fingerprint/scope fields before runtime edits. +2. Reuse the current reconciliation registry and service-owned write seam. +3. Add bounded, read-only adapters for `EvidenceSnapshot` and `ReviewPack` only. +4. Keep generic `StoredReport` families fail-closed in Spec 361 and record any future direct-proof need as a separate follow-up spec instead of widening this package. +5. Reuse current operations and artifact-truth presentation seams for operator-facing fallout. + +## Risk Controls + +- Fail closed on ambiguous, expired, partial, missing, or cross-scope artifacts. +- No adapter may write to artifact models or regenerate output. +- No new `stored_report.generate` or similar operation type may be introduced. +- If generic stored-report proof is still insufficient, the implementation must record an explicit defer note instead of widening scope. +- No new panel/provider, global search, asset registration, or destructive action may be added in this slice. + +## Implementation Phases + +### Phase 1: Baseline and Repo-Truth Inventory + +Confirm current operation types, adapter seams, and artifact models. Explicitly verify that `StoredReport` is still weaker than `EvidenceSnapshot` and `ReviewPack`. + +### Phase 2: Canonical Evidence Snapshot Reconciliation + +Add the evidence adapter, bounded proof checks, and focused operations/evidence tests. + +### Phase 3: Canonical Review Pack Reconciliation + +Add the review-pack adapter, bounded proof checks, current operations/detail fallout, and focused review-pack tests. + +### Phase 4: Unsupported Generic Report-Family Handling + +Keep `permission.posture.check` and `entra.admin_roles.scan` honest and diagnostic-first. Record defer/follow-up instead of inventing new truth. + +### Phase 5: Validation and Close-Out + +Run scoped Pest and Browser validation, confirm no migration/assets/panel drift, and record which artifact families were reconciled versus explicitly deferred. + +## Project Structure + +### Documentation (this feature) + +```text +specs/361-report-evidence-reconciliation/ +├── spec.md +├── plan.md +├── tasks.md +└── checklists/requirements.md +``` + +### Source Code (repository root) + +```text +apps/platform/app/Services/ +apps/platform/app/Support/Operations/Reconciliation/ +apps/platform/app/Support/OpsUx/ +apps/platform/app/Support/Ui/GovernanceArtifactTruth/ +apps/platform/app/Filament/Pages/ +apps/platform/app/Filament/Resources/ +apps/platform/tests/Unit/ +apps/platform/tests/Feature/ +apps/platform/tests/Browser/ +``` + +## Assumptions + +- The merged Spec 359 baseline and current `context.dispatch` seam remain in `platform-dev`. +- `EvidenceSnapshot` and `ReviewPack` retain their current status, fingerprint, and scope semantics. +- No production-compatibility requirement exists that would justify weak generic stored-report heuristics. + +## Open Preparation Decision + +Generic `StoredReport` reconciliation is intentionally excluded from the initial safe implementation slice because the current model lacks direct `operation_run_id` and readiness truth. This is not a blocker: the spec is explicitly narrowed around `EvidenceSnapshot` and `ReviewPack`, and unsupported report families must remain fail-closed. diff --git a/specs/361-report-evidence-reconciliation/spec.md b/specs/361-report-evidence-reconciliation/spec.md new file mode 100644 index 00000000..034bfce3 --- /dev/null +++ b/specs/361-report-evidence-reconciliation/spec.md @@ -0,0 +1,361 @@ +# Feature Specification: Spec 361 - Report and Evidence Reconciliation Adapters + +**Feature Branch**: `361-report-evidence-reconciliation` +**Created**: 2026-06-06 +**Status**: Draft +**Input**: User-provided Spec 361 draft from `/Users/ahmeddarrazi/.codex/attachments/a0817e2c-be58-40c1-9108-1e1880e423d4/pasted-text.txt`, reconciled against current repo truth + +## Spec Candidate Check *(mandatory - SPEC-GATE-001)* + +- **Problem**: current `OperationRun` monitoring can still leave report-family or evidence-family generation runs queued, running, or stale even after the repo already has a scope-safe artifact that proves the intended output exists. +- **Today's failure**: operators can be forced to compare Operations, Review Pack, Evidence Snapshot, and report surfaces manually to decide whether a run is genuinely unfinished or whether the artifact already exists and the run is now ghost state. +- **User-visible improvement**: existing operations surfaces, related-artifact links, and current artifact truth surfaces will show one honest execution story: reconcile only when a safe artifact already proves success, otherwise stay pending or attention-required without false calm. +- **Smallest enterprise-capable version**: extend the current `OperationRun` reconciliation registry for repo-verifiable artifact families only: `tenant.evidence.snapshot.generate` and `environment.review_pack.generate`. Keep generic `StoredReport` reconciliation fail-closed unless current repo truth already proves direct causal linkage without new persistence. +- **Explicit non-goals**: no new table, no new `OperationRun` status column, no new queue family, no new report renderer, no new PDF/HTML work, no new `stored_report.generate` operation type, no sync/backup/restore adapter expansion, no customer portal work, no destructive action changes, and no legacy compatibility layer. +- **Permanent complexity imported**: two bounded reconciliation adapters, at most one small derived proof/helper path, focused Unit/Feature/Browser tests, and small operator-facing copy or link adjustments on existing monitoring and artifact surfaces. +- **Why now**: Spec 359 merged the review-compose adapter baseline, and current `platform-dev` already contains the shared registry, `context.reconciliation`, and `context.dispatch` seams. The next repo-real trust gap is artifact-backed reconciliation for evidence and review-pack outputs. +- **Why not local**: fixing one banner, one presenter, or one detail page would not repair the shared run lifecycle truth across Operations, run detail, Review Pack, and Evidence Snapshot surfaces. +- **Approval class**: Core Enterprise +- **Red flags triggered**: new abstraction/helper pressure and shared operator-facing status semantics. Defense: the repo already has real adapter-backed reconciliation consumers (`restore.execute`, `environment.review.compose`), and queue correctness plus audit-visible truth justify a bounded extension instead of more special cases. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12** +- **Decision**: approve + +## Repo Truth Reconciliation + +The user draft is directionally correct, but current repo truth narrows the safe implementation slice: + +1. `docs/product/spec-candidates.md` still records `no safe automatic next-best-prep target`. This package is therefore an explicit manual promotion from the user-provided draft, not an auto-selected queue item. +2. `platform-dev` already contains the merged Spec 359 runtime baseline at `3a750726 feat: implement review compose reconciliation adapter (spec 359) (#430)`, plus the shared `OperationRunReconciliationRegistry`, `OperationRunCorrelationResolver`, and `context.dispatch` write path in `OperationRunService`. +3. `EvidenceSnapshot` and `ReviewPack` are safe primary artifact families for this slice because they already carry workspace/environment scope, lifecycle status, fingerprint data, and direct `operation_run_id` linkage. +4. Generic `StoredReport` truth is weaker than the draft assumes: the model has no direct `operation_run_id`, no status enum, and no canonical readiness lifecycle. Spec 361 therefore treats generic report reconciliation for `permission.posture.check` and `entra.admin_roles.scan` as explicitly unsupported. This package must not add any `StoredReport`-backed success path; any future direct report-artifact proof contract belongs in a separate follow-up spec. +5. Existing review-pack productization and report-output semantics from Specs 347, 349, 351, 356, and 357 are context only. This spec must reuse those seams and must not reopen rendered-report or disclosure-policy scope. +6. Related existing specs are context only and must not be rewritten: + - Spec 358 - generic queue-truth baseline + - Spec 359 - merged review-compose adapter baseline + - Spec 360 - canonical cutover follow-through context + - Specs 347/349/351/356/357 - review-pack and rendered-report productization context + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace, tenant, canonical-view +- **Primary Routes**: + - `/admin/workspaces/{workspace}/operations` + - `/admin/workspaces/{workspace}/operations/{run}` + - existing environment-scoped Evidence Snapshot routes via `App\Filament\Resources\EvidenceSnapshotResource` + - existing environment-scoped Review Pack routes via `App\Filament\Resources\ReviewPackResource` +- **Data Ownership**: + - `operation_runs` remain the only execution and reconciliation truth + - `evidence_snapshots` remain the only persisted evidence-generation truth + - `review_packs` remain the only persisted review-pack output truth + - `stored_reports` remain supporting artifact context only unless current repo truth proves safe direct reconciliation without new persistence +- **RBAC**: + - existing workspace-first `OperationRun` access rules remain authoritative + - existing environment-scoped `EvidenceSnapshot` and `ReviewPack` policies remain authoritative + - non-members and out-of-scope actors remain `404` + - no new capability strings are introduced + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: the Operations hub remains workspace-scoped with explicit environment filters; adapter decisions must rely on the run's recorded workspace/environment scope and never on remembered environment state or current page filters. +- **Explicit entitlement checks preventing cross-tenant leakage**: no adapter may reconcile to an artifact outside the run's workspace and managed environment, and any related-artifact link must resolve only through current-scope canonical routes. + +## UI Surface Impact *(mandatory - UI-COV-001)* + +Does this spec add, remove, rename, or materially change any reachable UI surface? + +- [ ] No UI surface impact +- [x] Existing page changed +- [ ] New page/route added +- [ ] Navigation changed +- [ ] Filament panel/provider surface changed +- [ ] New modal/drawer/wizard/action added +- [ ] New table/form/state added +- [ ] Customer-facing surface changed +- [ ] Dangerous action changed +- [x] Status/evidence/review presentation changed +- [ ] Workspace/environment context presentation changed + +## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact")* + +- **Route/page/surface**: + - workspace operations hub (`App\Filament\Pages\Monitoring\Operations`) + - canonical run detail (`App\Filament\Pages\Operations\TenantlessOperationRunViewer`) + - existing Evidence Snapshot related-context and detail surfaces + - existing Review Pack related-context and detail surfaces +- **Current or new page archetype**: existing monitoring/detail family plus existing governance-artifact detail family +- **Design depth**: Domain Pattern Surface +- **Repo-truth level**: repo-verified +- **Existing pattern reused**: current `OperationRun` monitoring family, current artifact-truth/detail family, current related-artifact link conventions +- **New pattern required**: none; this is a run-truth follow-through inside existing pattern families +- **Screenshot required**: no; one bounded browser smoke is sufficient unless implementation produces a materially new visible hierarchy +- **Page audit required**: no new page-report identity is required; existing monitoring and artifact page reports remain the anchors +- **Customer-safe review required**: no; touched copy remains operator-facing and diagnostic +- **Dangerous-action review required**: no; this spec adds no new mutation surface and no destructive action +- **Coverage files updated or explicitly not needed**: + - [ ] `docs/ui-ux-enterprise-audit/route-inventory.md` + - [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md` + - [ ] `docs/ui-ux-enterprise-audit/page-reports/...` + - [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md` + - [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md` + - [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md` + - [x] `N/A - existing monitoring and artifact page families already cover these reachable surfaces` +- **No-impact rationale when applicable**: no new page family, route family, or navigation entry is added; the slice stays inside existing operations, review-pack, and evidence artifact surfaces + +## 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, action links, evidence/report viewers, related-artifact diagnostics +- **Systems touched**: + - `App\Services\AdapterRunReconciler` + - `App\Services\OperationRunService` + - `App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry` + - `App\Support\OperationRunLinks` + - `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter` + - `App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder` + - existing Review Pack and Evidence Snapshot resource/detail surfaces +- **Existing pattern(s) to extend**: existing reconciliation metadata path, existing operations monitoring/detail language, current artifact-truth/detail family, and current canonical artifact links +- **Shared contract / presenter / builder / renderer to reuse**: `OperationRunService::updateRunWithReconciliation()`, `OperationRun::reconciliation()`, `OperationRunLinks`, `ArtifactTruthPresenter`, `GovernanceRunDiagnosticSummaryBuilder`, and current Evidence Snapshot / Review Pack resources +- **Why the existing shared path is sufficient or insufficient**: the repo already has the right write seam and related-artifact presentation family, but the registry does not yet cover repo-verifiable artifact-backed success for evidence or review-pack outputs. +- **Allowed deviation and why**: one bounded helper or proof object is allowed only if it removes duplicated scope/completeness checks across the two in-scope adapters. It must stay derived-only and local to current reconciliation work. +- **Consistency impact**: reconciled evidence or review-pack outcomes must read consistently across start feedback, operations list/detail, and related-artifact views, while unsupported generic stored-report families must stay attention-first instead of silently appearing successful. +- **Review focus**: no second reconciliation write path, no new stored-report lifecycle model, no artifact mutation from adapters, and no false success from "latest artifact exists" heuristics alone. + +## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)* + +- **Touches OperationRun start/completion/link UX?**: yes, reuse-only +- **Shared OperationRun UX contract/layer reused**: current `OperationRunService`, `OperationRunLinks`, registry-backed reconciliation, and existing operations monitoring/detail presenters +- **Delegated start/completion UX behaviors**: existing queued toasts, canonical run links, canonical artifact links, current run-enqueued browser events, and current terminal notification path remain unchanged +- **Local surface-owned behavior that remains**: placement and explanation text only +- **Queued DB-notification policy**: unchanged +- **Terminal notification path**: unchanged central lifecycle mechanism +- **Exception required?**: none + +## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)* + +- **Shared provider/platform boundary touched?**: no +- **Boundary classification**: N/A +- **Seams affected**: N/A +- **Neutral platform terms preserved or introduced**: `operation`, `artifact`, `review pack`, `evidence snapshot`, `reconciliation` +- **Provider-specific semantics retained and why**: none +- **Why this does not deepen provider coupling accidentally**: the slice uses platform-owned artifact and operation truth only +- **Follow-up path**: none + +## 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 | +|---|---|---|---|---|---|---| +| Operations hub reconciliation copy and related-artifact state | yes | Native Filament + shared presenters | `OperationRun` monitoring family | page, detail, URL-query | no | no new action hierarchy | +| Run detail reconciliation diagnostics and related links | yes | Native Filament + shared presenters | `OperationRun` monitoring family | detail | no | existing link model stays canonical | +| Evidence Snapshot / Review Pack related-artifact surfaces | yes | Native Filament + shared artifact-truth helpers | artifact detail family | detail | no | no new route family or mutation | + +## 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 | +|---|---|---|---|---|---|---|---| +| Operations hub | Primary Decision Surface | Decide whether a run still needs follow-up | lifecycle, outcome, safe related-artifact state, next action | raw failures, reconciliation evidence, artifact details | primary because operators decide whether to wait, inspect, or move on here | stays aligned to monitoring workflow | removes manual cross-checking between run and artifact lists | +| Run detail | Secondary Context Surface | Verify why a run was reconciled or left pending | reconciled reason, related artifact, scope-safe link | full context payload, existing diagnostic sections | secondary because it confirms a decision already hinted in the hub | stays aligned to detail workflow | keeps proof in one canonical place | +| Evidence Snapshot / Review Pack detail | Tertiary Evidence / Diagnostics | Inspect the artifact that justified or blocked reconciliation | current artifact state | payloads, summary metadata, existing artifact diagnostics | tertiary because artifact pages prove evidence after the run decision | stays aligned to current artifact detail workflow | avoids duplicating full artifact truth on the run list | + +## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)* + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Operations hub | operator-MSP, support-platform | run status, honest reconciliation state, safe related artifact, next action | failures, related metadata, timestamps after explicit reveal by an actor already entitled to view the run | raw `context.reconciliation` evidence remains secondary and stays limited to the existing support/platform diagnostics path on the run host | `Open operation` or related artifact link | raw context stays diagnostic-only and is never default-visible | the hub states the outcome once; later sections add proof only | +| Run detail | operator-MSP, support-platform | reconciled or not reconciled state with safe reason | full reconciliation metadata and existing failure detail after explicit reveal by an actor already entitled to view the run | raw payload inspection stays secondary and remains available only through the existing support/platform diagnostics path on the run host | `Open related artifact` when safe | low-level payloads remain subordinate and capability-gated where the host already distinguishes support/platform access | no second "success" summary beyond the canonical outcome | +| Evidence Snapshot / Review Pack detail | operator-MSP, support-platform | current artifact state, scope-safe relation to the run, and concise artifact outcome summary | existing artifact diagnostics and related run context after explicit reveal by an actor already entitled to view the artifact | raw/support artifact evidence remains behind the existing host-specific diagnostics or payload affordance and never becomes the default first-read layer | `Open related operation` when safe | payloads, fingerprints, and raw evidence stay secondary and host-gated | artifact pages prove or explain one outcome only; they do not restate a competing default-visible success summary | + +Layer unlocks for changed surfaces: + +- **Decision content**: any actor who already passes the current workspace/environment and run/artifact view entitlement for the host surface. +- **Operator diagnostics**: the same entitled actor, but only after an explicit reveal affordance on the host surface. +- **Support / raw evidence**: only the existing support/platform diagnostics path on the current host surface; this spec introduces no new capability string and no widened raw-evidence audience. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Operations hub | Monitoring / Queue / Workbench | Read-only Registry / Report Surface | Open the run or its proven artifact | row click to run detail | required | existing filters and contextual links only | unchanged | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | workspace route plus explicit environment filter | Operations / Operation | honest lifecycle plus safe artifact state | none | +| Run detail | Record / Detail / Edit | Detail-first Operational Surface | Inspect related artifact proof | canonical detail page | N/A | existing navigation and related links only | unchanged | `/admin/workspaces/{workspace}/operations` | `/admin/workspaces/{workspace}/operations/{run}` | run-owned workspace/environment scope | Operation | reconciliation outcome and reason | none | +| Evidence Snapshot / Review Pack detail | Record / Detail / Edit | Detail-first Operational Surface | Inspect the artifact proof and, if needed, follow the safe link back to the operation | canonical detail page | N/A | existing related links and host-owned secondary actions only | unchanged | existing Evidence Snapshot / Review Pack collection routes | existing Evidence Snapshot / Review Pack detail routes | artifact-owned workspace/environment scope plus current host context | Evidence Snapshot / Review Pack | current artifact truth and safe relation to the reconciled run | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Operations hub | MSP operator | Decide whether the run still needs attention | list/workbench | Is the artifact already proven, or is this run still unfinished? | lifecycle state, reconciled outcome, safe artifact state, next step | raw failures, detailed metadata, timestamps | lifecycle, artifact completeness, scope safety | none | existing inspect/open actions only | none | +| Run detail | MSP operator or support | Confirm why the run reconciled or stayed pending | detail | What exact artifact truth did the run resolve against? | reconciled reason, related artifact, summary counts | raw `context.reconciliation`, raw context payload, deep diagnostic sections | lifecycle, artifact completeness, support state | none | open related artifact when safe | none | +| Evidence Snapshot / Review Pack detail | MSP operator or support | Confirm whether the artifact really justifies or blocks reconciliation | detail | Does this artifact safely prove the run outcome for the current scope? | artifact readiness, scope-safe linkage, concise supporting summary | payloads, fingerprints, deeper host diagnostics, and related run context | artifact readiness, completeness/expiration, scope safety | none | open related operation when safe | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes, bounded adapter classes and at most one small derived proof/helper path +- **New enum/state/reason family?**: no new family; only current reconciliation reason semantics may be reused or narrowly extended if behaviorally required +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: artifact-producing runs can remain queued/running/stale even after the repo already has a safe artifact, forcing manual cross-checks and risking false calm or false alarm. +- **Existing structure is insufficient because**: the current registry only covers restore and review-compose truth, while evidence and review-pack runs still lack artifact-backed reconciliation even though their artifact models already expose scope, lifecycle, and fingerprint proof. +- **Narrowest correct implementation**: add only evidence snapshot and review-pack adapters over existing models and write seams; keep generic stored-report reconciliation deferred until direct causal proof exists. +- **Ownership cost**: adapter maintenance, focused tests, and some monitoring/detail copy review. No migration, new artifact table, or new operator workflow family is introduced. +- **Alternative intentionally rejected**: a generic "latest artifact exists => mark success" heuristic was rejected because it would overclaim success for unsupported or ambiguous report families, especially `StoredReport`. +- **Release truth**: current-release truth + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)* + +- **Test purpose / classification**: Unit, Feature, Browser +- **Validation lane(s)**: fast-feedback, confidence, browser +- **Why this classification and these lanes are sufficient**: adapter selection, proof evaluation, and metadata formatting are cheapest in Unit coverage; run finalization, scope safety, and artifact-link behavior require Feature coverage; one bounded Browser smoke is enough for existing Operations and artifact surfaces if visible operator wording changes. +- **New or expanded test families**: one explicit Spec 361 Unit/Feature family and one bounded Browser smoke +- **Fixture / helper cost impact**: moderate existing factory usage for `OperationRun`, `EvidenceSnapshot`, `ReviewPack`, `EnvironmentReview`, and workspace/environment membership; no new expensive global defaults should be introduced +- **Heavy-family visibility / justification**: no heavy-governance family is added +- **Special surface test profile**: monitoring-state-page +- **Standard-native relief or required special coverage**: ordinary Feature coverage for existing Filament surfaces plus one bounded Browser smoke when UI wording changes +- **Reviewer handoff**: reviewers must confirm generic `StoredReport` families stay fail-closed unless safe proof is demonstrated, that adapters do not mutate artifacts, and that proof commands stay scoped to Spec 361 plus contextual artifact regressions only +- **Budget / baseline / trend impact**: none expected beyond one bounded browser smoke +- **Escalation needed**: document-in-feature if generic stored-report proof remains insufficient; follow-up-spec only if the repo later needs a dedicated stored-report causality contract +- **Active feature PR close-out entry**: Guardrail / Smoke Coverage +- **Planned validation commands**: + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations/Reconciliation tests/Feature/Operations/Spec361*` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec361ArtifactReconciliationSmokeTest.php` + - `cd apps/platform && ./vendor/bin/pint --dirty` + - `git diff --check` + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Reconcile evidence generation against an existing snapshot (Priority: P1) + +An operator sees a queued, running, or stale evidence-generation run and needs the run to finalize honestly when the matching `EvidenceSnapshot` already exists and is scope-safe, complete, and active. + +**Why this priority**: `EvidenceSnapshot` already has direct `operation_run_id`, lifecycle status, completeness state, and fingerprint signals, so this is the safest and highest-confidence artifact-backed reconciliation extension. + +**Independent Test**: seed a queued or stale `tenant.evidence.snapshot.generate` run plus a matching active, complete snapshot in the same workspace/environment, run reconciliation, and verify the run finalizes successfully with canonical related-artifact metadata. + +**Acceptance Scenarios**: + +1. **Given** a queued or stale evidence-generation run and a matching active, complete snapshot with the same scope and fingerprint, **When** adapter reconciliation runs, **Then** the run becomes completed/succeeded and links to the snapshot safely. +2. **Given** a queued or stale evidence-generation run and a partial, failed, expired, or wrong-scope snapshot, **When** adapter reconciliation runs, **Then** the run does not claim success and remains pending or attention-required. + +--- + +### User Story 2 - Reconcile review-pack generation against an existing pack (Priority: P1) + +An operator sees a queued, running, or stale review-pack generation run and needs the run to finalize honestly when the matching `ReviewPack` already exists, is ready, non-expired, and belongs to the same workspace/environment/review scope. + +**Why this priority**: review-pack output is already customer-facing and productized; ghost run state here directly damages trust in delivered governance artifacts. + +**Independent Test**: seed a queued or stale `environment.review_pack.generate` run plus a matching ready pack with canonical scope and fingerprint signals, run reconciliation, and verify the run finalizes successfully with canonical related-artifact metadata. + +**Acceptance Scenarios**: + +1. **Given** a queued or stale review-pack run and a matching ready, non-expired pack for the same scope, **When** adapter reconciliation runs, **Then** the run becomes completed/succeeded and exposes the safe pack link through current operations surfaces. +2. **Given** a queued or stale review-pack run and a generating, failed, expired, incomplete, or wrong-scope pack, **When** adapter reconciliation runs, **Then** the run does not claim success and the operator sees honest follow-up state instead. + +--- + +### User Story 3 - Keep unsupported generic report families honest (Priority: P2) + +An operator sees a queued or stale run for a report-producing operation such as `permission.posture.check` or `entra.admin_roles.scan` and must not see false success merely because a latest `StoredReport` exists without direct causal proof. + +**Why this priority**: the current `StoredReport` model is not strong enough to justify blind success. Preserving fail-closed behavior is a product-truth requirement and prevents overclaiming. + +**Independent Test**: seed queued or stale report-producing runs plus retained `StoredReport` rows and verify the run stays unresolved or diagnostic-first because Spec 361 does not allow any `StoredReport`-backed success path. + +**Acceptance Scenarios**: + +1. **Given** a report-producing run and only a latest retained `StoredReport` with no direct causal proof, **When** reconciliation runs, **Then** the run does not finalize as succeeded from that report alone. +2. **Given** a future repo-real direct report-artifact proof path is absent, **When** implementation reaches that branch, **Then** the feature records a defer/follow-up note instead of inventing a new persistence or lifecycle model. + +### Edge Cases + +- Multiple matching artifacts exist and no single current-scope winner is provable. +- An artifact exists but belongs to another workspace or managed environment. +- A snapshot is `active` but `partial`, `missing`, or otherwise incomplete. +- A review pack is `ready` but expired or no longer the current export for the review context. +- A run already has reconciliation metadata from another source and the adapter should not overwrite terminal truth incorrectly. +- A generic `StoredReport` looks current but still cannot prove causal linkage to the run that is being reconciled. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-361-001**: New artifact-backed reconciliation MUST reuse the current `OperationRunReconciliationRegistry`, `OperationRunReconciliationAdapter` contract, `AdapterRunReconciler`, and `OperationRunService::updateRunWithReconciliation()` write seam. No second registry or direct model write path is allowed. +- **FR-361-002**: The feature MUST implement bounded reconciliation for `tenant.evidence.snapshot.generate` using existing `EvidenceSnapshot` scope, status, fingerprint, completeness, and `operation_run_id` truth. +- **FR-361-003**: The feature MUST implement bounded reconciliation for `environment.review_pack.generate` using existing `ReviewPack` scope, status, fingerprint, expiration, and `operation_run_id` truth. +- **FR-361-004**: Unsupported generic report families, especially `permission.posture.check` and `entra.admin_roles.scan`, MUST remain fail-closed in Spec 361 and MUST NOT gain any `StoredReport`-backed success path in this package. +- **FR-361-005**: Any shared proof helper introduced for this feature MUST remain derived-only, read-only, and local to current reconciliation scope; it MUST NOT create new persisted truth or a generic artifact workflow engine. +- **FR-361-006**: Evidence and review-pack adapters MUST validate workspace/environment scope before success. If present, they SHOULD also validate direct `operation_run_id`, fingerprint, review scope, or other current repo-real identity signals. +- **FR-361-007**: Evidence reconciliation MUST require a snapshot state that is good enough to prove success: `active` plus non-missing completeness. Partial, failed, expired, stale, or wrong-scope states must not succeed silently. +- **FR-361-008**: Review-pack reconciliation MUST require a pack state that is good enough to prove success: `ready`, non-expired, current-scope, and tied to the same review/evidence context when current repo truth exposes those links. +- **FR-361-009**: All successful artifact-backed reconciliation MUST persist canonical metadata only through `context.reconciliation`, including reason, adapter key, previous lifecycle state, and related artifact reference. +- **FR-361-010**: Adapters MUST be read-only toward `EvidenceSnapshot`, `ReviewPack`, and `StoredReport`. They may read and compare; they may not mutate artifact lifecycle or regenerate output. +- **FR-361-011**: Existing operations surfaces MUST explain reconciled or unsupported states calmly and truthfully without adding a new route family, a new page family, or raw storage/debug data as the primary visible message. +- **FR-361-012**: The feature MUST NOT widen restore, backup, sync, or review-compose behavior beyond the current registry-owned contract. + +### Non-Functional Requirements + +- **NFR-361-001**: The feature MUST fail closed on ambiguous, partial, expired, stale, or cross-scope artifact states. +- **NFR-361-002**: The feature MUST preserve auditability by keeping reconciliation writes inside the current `OperationRun` lifecycle path. +- **NFR-361-003**: The feature MUST be idempotent; re-running reconciliation over the same eligible artifact must not create duplicate lifecycle churn. +- **NFR-361-004**: The feature SHOULD avoid migrations. If implementation somehow proves a migration is required, work must stop and spec/plan must be updated before coding further. +- **NFR-361-005**: The feature MUST not add compatibility shims for pre-production historical report aliases or old fixture shapes. +- **NFR-361-006**: The feature adds no destructive action, so no new confirmation flows are expected. +- **NFR-361-007**: Filament and Livewire posture remains current repo truth: Filament v5 on Livewire v4, with provider registration in `apps/platform/bootstrap/providers.php`. +- **NFR-361-008**: No new Filament asset registration is allowed; `filament:assets` remains unchanged. +- **NFR-361-009**: Changed operator-facing surfaces MUST keep default-visible decision content above diagnostics, keep raw/support evidence behind existing host-gated reveal paths, and preserve exactly one dominant default next action. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Operations hub | `apps/platform/app/Filament/Pages/Monitoring/Operations.php` | existing only | row click to canonical detail | existing only | existing only | unchanged | N/A | N/A | existing run lifecycle audit only | no new action | +| Run detail | `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` | existing only | N/A | N/A | N/A | unchanged | existing navigation only | N/A | existing run lifecycle audit only | no new action | +| Evidence Snapshot detail | `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` | existing only | row click remains canonical | existing only | existing only | unchanged | existing only | N/A | existing audit only | related run or artifact copy only | +| Review Pack detail | `apps/platform/app/Filament/Resources/ReviewPackResource.php` | existing only | row click remains canonical | existing only | existing only | unchanged | existing only | N/A | existing audit only | no new mutation surface | + +### Key Entities *(include if feature involves data)* + +- **OperationRun**: persisted execution and reconciliation truth; owns `type`, `status`, `outcome`, `summary_counts`, and `context.reconciliation` +- **EvidenceSnapshot**: persisted evidence-generation truth with `operation_run_id`, status, completeness state, scope, and fingerprint +- **ReviewPack**: persisted review-pack artifact truth with `operation_run_id`, status, scope, expiration, and fingerprint +- **StoredReport**: retained report artifact context only; currently lacks direct causal linkage strong enough for generic reconciliation +- **ReconciliationResult / proof helper**: bounded derived decision output only; no new persistence + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-361-001**: queued, running, or stale `tenant.evidence.snapshot.generate` runs can be reconciled to an existing active, current-scope snapshot without adding new persistence or new operation types. +- **SC-361-002**: queued, running, or stale `environment.review_pack.generate` runs can be reconciled to an existing ready, non-expired, current-scope pack without adding new persistence or rendered-report scope. +- **SC-361-003**: operations surfaces show one canonical artifact-backed success or non-success story, and unsupported generic `StoredReport` families do not silently appear successful. +- **SC-361-004**: focused Unit, Feature, and bounded Browser validation passes with no new asset strategy, no migration, and no new destructive action. + +## Assumptions + +- Spec 359 runtime is already merged and remains the baseline adapter consumer next to restore reconciliation. +- Current `platform-dev` already contains the shared `context.dispatch` seam and `OperationRunCorrelationResolver`. +- `EvidenceSnapshot` and `ReviewPack` lifecycle fields remain the current repo-real truth for artifact readiness. +- Generic `StoredReport` causality is intentionally out of scope unless implementation can prove it without new persistence or compatibility logic. + +## Risks + +- A helper introduced for shared proof checks could drift into a generic artifact framework if review is not strict about the two-family boundary. +- Review-pack or evidence detail copy could overclaim success if it exposes "artifact exists" without retaining completeness or scope checks. +- Implementers may be tempted to rescue generic `StoredReport` families with weak heuristics; this must be treated as out-of-scope creep unless the spec is updated first. + +## Open Questions + +- None blocking prep. The main repo-truth deviation is already resolved in this spec: generic `StoredReport` reconciliation is not part of the initial safe implementation slice. + +## Follow-up Spec Candidates + +- **Stored-report causal reconciliation follow-through**: if a future repo slice introduces direct `StoredReport` causal linkage, readiness lifecycle, or canonical report-output proof strong enough for safe success reconciliation, promote a separate follow-up instead of widening Spec 361 silently. diff --git a/specs/361-report-evidence-reconciliation/tasks.md b/specs/361-report-evidence-reconciliation/tasks.md new file mode 100644 index 00000000..6143493e --- /dev/null +++ b/specs/361-report-evidence-reconciliation/tasks.md @@ -0,0 +1,203 @@ +# Tasks: Spec 361 - Report and Evidence Reconciliation Adapters + +**Input**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/361-report-evidence-reconciliation/spec.md`, `plan.md`, and `checklists/requirements.md` +**Prerequisites**: `spec.md` and `plan.md` +**Tests**: REQUIRED (Pest). Keep proof bounded to Unit + Feature + one explicit Browser smoke. +**Operations**: Reuse current `OperationRun` lifecycle ownership. No new run status column, no new queue family, and no new schema. +**RBAC**: Reuse current workspace-first `OperationRun` access plus existing `EvidenceSnapshot` and `ReviewPack` policies. No new capability strings and no cross-scope artifact resolution. +**Shared Pattern Reuse**: Reuse `OperationRunService`, current operations surfaces, `OperationRunLinks`, `ArtifactTruthPresenter`, and current evidence/review-pack detail families. Introduce only bounded artifact-specific adapters plus at most one small derived proof/helper path. +**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration stays in `apps/platform/bootstrap/providers.php`. No new panel, global-search change, or asset strategy is allowed. +**Organization**: Tasks are grouped by user story so Evidence Snapshot reconciliation, Review Pack reconciliation, and honest unsupported-report handling remain independently reviewable. + +## Repo Baseline At Prep Time + +- **Branch**: `361-report-evidence-reconciliation` +- **HEAD**: `840c9bd2 refactor: rename ManagedEnvironment context badge to Environment context (#431)` +- **`git status --short --branch` before Spec 361 prep**: clean on `platform-dev`; Spec Kit created this feature branch and copied the spec/plan templates +- **Merged adapter baseline**: `3a750726 feat: implement review compose reconciliation adapter (spec 359) (#430)` is already in repo history and remains the adapter-registry baseline +- **Relevant runtime surfaces**: + - `apps/platform/app/Services/AdapterRunReconciler.php` + - `apps/platform/app/Services/OperationRunService.php` + - `apps/platform/app/Models/OperationRun.php` + - `apps/platform/app/Models/EvidenceSnapshot.php` + - `apps/platform/app/Models/ReviewPack.php` + - `apps/platform/app/Models/StoredReport.php` + - `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` + - `apps/platform/app/Services/ReviewPackService.php` + - `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php` + - `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` + - `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php` + - `apps/platform/app/Filament/Pages/Monitoring/Operations.php` + - `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php` + - `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` + - `apps/platform/app/Filament/Resources/ReviewPackResource.php` +- **Completed-spec context only**: Specs 347, 349, 351, 356, and 357 are context only and must not be reopened for rendered-report or disclosure-policy scope +- **Scope guardrail**: generic `StoredReport` causality is context only unless direct proof is stronger than expected during implementation + +## 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 the Browser addition remains 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 widening into unrelated lane cost. +- [x] The declared monitoring/detail surface profile is explicit. +- [x] Any material budget, baseline, trend, or escalation note is recorded in the active feature close-out. + +## Phase 1: Setup (Repo Truth Inventory) + +**Purpose**: confirm the current registry baseline, the artifact models, and the exact unsupported report-family boundary before runtime edits begin. + +- [x] T001 Re-read `spec.md`, `plan.md`, `checklists/requirements.md`, `.specify/memory/constitution.md`, `docs/ai-coding-rules.md`, `docs/architecture-guidelines.md`, `docs/testing-guidelines.md`, `docs/security-guidelines.md`, `docs/filament-guidelines.md`, and `specs/358-operationrun-queue-truth-foundation/{spec,plan,tasks}.md` plus `specs/359-operationrun-reconciliation-adapter-framework-review-compose-adapter/{spec,plan,tasks}.md` and `specs/360-operationrun-canonical-cutover-cleanup/{spec,plan,tasks}.md` together before touching runtime code. +- [x] T002 [P] Confirm the current adapter and write seams in `apps/platform/app/Services/AdapterRunReconciler.php`, `apps/platform/app/Services/OperationRunService.php`, `apps/platform/app/Models/OperationRun.php`, and `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php`. +- [x] T003 [P] Confirm the current artifact-truth seams in `apps/platform/app/Models/EvidenceSnapshot.php`, `apps/platform/app/Models/ReviewPack.php`, `apps/platform/app/Models/StoredReport.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/ReviewPackService.php`, and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`. +- [x] T004 [P] Confirm the current canonical operation types and run-start contexts for `tenant.evidence.snapshot.generate`, `environment.review_pack.generate`, `permission.posture.check`, and `entra.admin_roles.scan` in `apps/platform/app/Support/OperationCatalog.php`, `apps/platform/app/Support/OperationRunType.php`, and the current job/service start paths. +- [x] T005 Confirm that no new schema, no new panel/provider path, no new asset registration, no new `stored_report.generate` operation type, and no generic stored-report lifecycle model are required; if any of those are needed, stop and update the spec/plan instead of widening scope silently. + +--- + +## Phase 2: Foundational (Bounded Artifact-Proof and Registry Setup) + +**Purpose**: settle the bounded proof checks and registry shape before story-specific adapter work begins. + +**Critical**: no user-story runtime work should begin until this phase is complete. + +- [x] T006 [P] Add failing Unit coverage in `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactRegistryResolutionTest.php` for adapter resolution of `tenant.evidence.snapshot.generate`, `environment.review_pack.generate`, and unsupported generic report families. +- [x] T007 [P] Add failing Unit coverage in `apps/platform/tests/Unit/Support/Operations/Reconciliation/Spec361ArtifactProofRulesTest.php` for scope checks, completeness checks, expiration handling, canonical related-identity tie-breaking, and fail-closed unsupported-report behavior. +- [x] T008 No bounded shared helper was needed; evidence and review-pack proof logic stayed local to their adapters. +- [x] T009 Add `EvidenceSnapshotReconciliationAdapter` and `ReviewPackArtifactReconciliationAdapter` (or equivalent bounded names) under `apps/platform/app/Support/Operations/Reconciliation/` and register them in `OperationRunReconciliationRegistry`. +- [x] T010 Keep generic `StoredReport` reconciliation out of the registry in Spec 361. Do not add any `StoredReport`-backed success path in this package; record a named follow-up spec instead of a heuristic adapter. +- [x] T011 Update or add Unit coverage proving that adapters never mutate `EvidenceSnapshot`, `ReviewPack`, or `StoredReport`, that all lifecycle writes still go through `OperationRunService`, that `context.reconciliation` keeps the canonical adapter or reason or previous-state or related-artifact shape, and that rerunning reconciliation is idempotent. + +**Checkpoint**: the registry is ready for artifact-backed extensions, and the unsupported report-family boundary is explicit. + +--- + +## Phase 3: User Story 1 - Reconcile evidence generation against an existing snapshot (Priority: P1) + +**Goal**: `tenant.evidence.snapshot.generate` runs can finalize against an existing current-scope snapshot when current repo truth already proves success. + +**Independent Test**: run focused Unit and Feature coverage showing that a queued/running/stale evidence-generation run finalizes successfully only when a matching active, non-partial snapshot already exists. + +### Tests for User Story 1 + +- [x] T012 [P] [US1] Add `apps/platform/tests/Feature/Operations/Spec361EvidenceSnapshotReconciliationTest.php` covering active complete success, partial rejection, failed/expired rejection, wrong-scope rejection, and ambiguous-candidate rejection. +- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php` or a focused companion file so stale queued/running evidence-generation runs can prove the adapter-backed finalization path without changing artifact generation semantics. + +### Implementation for User Story 1 + +- [x] T014 [US1] Implement `EvidenceSnapshotReconciliationAdapter` under `apps/platform/app/Support/Operations/Reconciliation/` using existing `operation_run_id`, `managed_environment_id`, `workspace_id`, `fingerprint`, `status`, and `completeness_state` truth. +- [x] T015 [US1] Reuse `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` and `apps/platform/app/Services/OperationRunService.php` without adding a new evidence lifecycle or persistence layer. +- [x] T016 [US1] Update existing operations/detail presentation paths in `apps/platform/app/Support/OpsUx/GovernanceRunDiagnosticSummaryBuilder.php`, `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`, `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` only as needed so reconciled evidence runs explain the matched snapshot calmly, scope-safely, and without duplicate default-visible truth. + +**Checkpoint**: evidence-generation runs reconcile safely from current snapshot truth and never overclaim partial or wrong-scope success. + +--- + +## Phase 4: User Story 2 - Reconcile review-pack generation against an existing pack (Priority: P1) + +**Goal**: `environment.review_pack.generate` runs can finalize against an existing ready, non-expired pack when current repo truth already proves success. + +**Independent Test**: run focused Unit and Feature coverage showing that a queued/running/stale review-pack run finalizes successfully only when a matching ready, current-scope pack already exists. + +### Tests for User Story 2 + +- [x] T017 [P] [US2] Add `apps/platform/tests/Feature/Operations/Spec361ReviewPackReconciliationTest.php` covering ready success plus queued/generating/failed/expired/wrong-scope rejection. +- [x] T018 [P] [US2] Extend focused review-pack coverage in `apps/platform/tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php`, `apps/platform/tests/Feature/ReviewPack/ReviewPackResourceTest.php`, or a companion file only as needed to prove canonical related-artifact metadata, current host detail fallout, and no false success on incomplete or non-shareable output. + +### Implementation for User Story 2 + +- [x] T019 [US2] Implement `ReviewPackArtifactReconciliationAdapter` under `apps/platform/app/Support/Operations/Reconciliation/` for `environment.review_pack.generate`, using existing `ReviewPack` scope, status, expiration, fingerprint, `operation_run_id`, and review/evidence linkage where current repo truth exposes it. +- [x] T020 [US2] Reuse `apps/platform/app/Services/ReviewPackService.php`, `apps/platform/app/Support/OperationRunLinks.php`, and `apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php` so reconciled runs link to the current pack without exposing raw storage or expired artifacts. +- [x] T021 [US2] Keep customer-safe and operator-safe boundaries intact: no new download mutation, no rendered-report rewrite, no new readiness taxonomy, and no reopening of Specs 347/349/351/356/357 scope. + +**Checkpoint**: review-pack generation runs reconcile safely from current pack truth and never overclaim expired or still-generating output. + +--- + +## Phase 5: User Story 3 - Keep unsupported generic report families honest (Priority: P2) + +**Goal**: report-producing runs backed only by weak `StoredReport` truth stay fail-closed instead of falsely succeeding from "latest report exists" heuristics. + +**Independent Test**: run focused Unit and Feature coverage showing that `permission.posture.check` and `entra.admin_roles.scan` remain unresolved or diagnostic-first because Spec 361 keeps `StoredReport` success reconciliation out of scope. + +### Tests for User Story 3 + +- [x] T022 [P] [US3] Add focused Unit or Feature coverage in `apps/platform/tests/Feature/Operations/Spec361UnsupportedStoredReportReconciliationTest.php` proving that retained `StoredReport` rows alone do not reconcile `permission.posture.check` or `entra.admin_roles.scan`. +- [x] T023 [P] [US3] Add or extend operations-detail coverage in `apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php` or a companion Spec 361 file so unsupported report-family runs remain attention-first, keep one dominant default next action, keep diagnostics secondary, and do not expose raw/support evidence in the default-visible layer for ordinary operators. + +### Implementation for User Story 3 + +- [x] T024 [US3] Verify that generic `StoredReport` families remain excluded from the Spec 361 registry path and record the named follow-up spec in the feature close-out instead of widening this package. +- [x] T025 [US3] No unsupported-report helper was needed; stored-report families stayed on existing operations diagnostics and artifact-truth paths without a new lifecycle model. +- [x] T026 [US3] Ensure `permission.posture.check`, `entra.admin_roles.scan`, restore, sync, backup, and review-compose semantics remain unchanged except for clearer unsupported or related-artifact diagnostics on current operations surfaces and existing host-gated disclosure paths. + +**Checkpoint**: the feature improves artifact-backed truth where repo proof is strong and explicitly refuses false success where proof is weak. + +--- + +## Phase 6: Polish & Validation + +- [ ] T027 [P] Refresh `spec.md`, `plan.md`, and `checklists/requirements.md` only if implementation proves a thinner touched-file boundary or requires an explicit recorded defer for generic stored-report causality. +- [x] T028 [P] Run the primary in-scope Unit and Feature gate: + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/Support/Operations/Reconciliation tests/Feature/Operations/Spec361*` +- [x] T029 [P] Run the bounded contextual artifact regressions: + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php` +- [x] T030 [P] Run the primary browser smoke and verify the changed operations surfaces still show one dominant next action, keep duplicate visible decision truth out of the default layer, and keep deeper diagnostics behind explicit reveal paths: + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec361ArtifactReconciliationSmokeTest.php` +- [ ] T031 [P] Run the bounded contextual browser smoke only if visible copy or link behavior changes on related surfaces: + - `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php tests/Browser/Spec337EvidenceReviewPackProductFlowSmokeTest.php` +- [x] T032 [P] Run `cd apps/platform && ./vendor/bin/pint --dirty`. +- [x] T033 [P] Run `git diff --check`. +- [x] T034 [P] Record the final artifact families reconciled, the explicit unsupported generic report-family decision, validation results, no-migration status, no-asset status, and the final Guardrail / Smoke Coverage note in the active feature close-out. + +## Close-Out Notes + +- Reconciled artifact families: `tenant.evidence.snapshot.generate` via `EvidenceSnapshotReconciliationAdapter` and `environment.review_pack.generate` via `ReviewPackArtifactReconciliationAdapter`. +- Explicit unsupported generic report-family decision: `permission.posture.check` and `entra.admin_roles.scan` remain outside the adapter registry because `StoredReport` still lacks direct lifecycle truth and causal linkage; any future auto-success path needs a follow-up spec. +- No migration status: no schema or persisted-truth changes were required. +- No asset / panel status: no Filament panel/provider/global-search/asset changes were required; Laravel 12 provider registration remains in `apps/platform/bootstrap/providers.php`, and Filament stays on v5 with Livewire v4. +- Guardrail / Smoke Coverage: Spec 361 added bounded Unit + Feature coverage for registry shape, canonical related IDs, artifact-safe success/fail-closed behavior, and a Browser smoke for evidence snapshot and review-pack drill-through on existing Operations surfaces. +- Validation results: + - `cd apps/platform && php vendor/bin/pest tests/Unit/Support/Operations/Reconciliation tests/Feature/Operations/Spec359OperationRunAdapterReconciliationTest.php tests/Feature/Operations/Spec360CanonicalReconciliationCutoverTest.php tests/Feature/Operations/Spec361EvidenceSnapshotReconciliationTest.php tests/Feature/Operations/Spec361ReviewPackReconciliationTest.php tests/Feature/Operations/Spec361UnsupportedStoredReportReconciliationTest.php tests/Feature/EnvironmentReview/Spec359ReviewComposeReconciliationTest.php tests/Feature/EnvironmentReview/Spec360ReviewComposeAdapterOwnershipTest.php tests/Feature/Evidence/GenerateEvidenceSnapshotJobTest.php tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/Spec347ReviewPackOutputContractTest.php` -> 55 passed, 443 assertions. + - `cd apps/platform && php vendor/bin/pest tests/Browser/Spec361ArtifactReconciliationSmokeTest.php` -> 2 passed, 20 assertions. + - `cd apps/platform && php vendor/bin/pint --dirty` -> fixed 3 style issues across 19 files. + - `git diff --check` -> clean. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: no dependencies +- **Foundational (Phase 2)**: depends on Setup and blocks all story work +- **US1 (Phase 3)**: depends on Foundational completion +- **US2 (Phase 4)**: depends on Foundational completion; can land after US1 or in parallel once shared proof rules settle +- **US3 (Phase 5)**: depends on US1 and US2 because the unsupported-family boundary should be validated against the final in-scope adapter set +- **Polish (Phase 6)**: depends on all desired user stories + +### Parallel Opportunities + +- `T002`, `T003`, and `T004` can run in parallel. +- `T006` and `T007` can run in parallel. +- `T012` and `T013` can run in parallel. +- `T017` and `T018` can run in parallel. +- `T022` and `T023` can run in parallel. +- `T028` through `T033` can run in parallel once implementation stabilizes, but the primary merge gate should be read out separately from contextual regressions. + +### Implementation Strategy + +1. Freeze the current registry and artifact-model baseline first. +2. Land the bounded proof rules and registry setup before story-specific runtime edits. +3. Land Evidence Snapshot reconciliation first because its proof path is strongest. +4. Land Review Pack reconciliation second because it is customer-visible and already repo-real. +5. Finish by locking fail-closed behavior for weak generic report families and recording any explicit defer. + +## Non-Goals / Must-Not-Do + +- [ ] NT001 Do not add a new `OperationRun` status column, boolean, or separate reconciliation table. +- [ ] NT002 Do not add a `stored_report.generate` operation type, a generic report-artifact lifecycle model, or a new rendered-report workflow. +- [ ] NT003 Do not mutate `EvidenceSnapshot`, `ReviewPack`, or `StoredReport` from adapters. +- [ ] NT004 Do not widen scope into backup, restore, sync, review-compose, PDF/HTML rendering, disclosure policy, or customer portal work. +- [ ] NT005 Do not add compatibility shims or heuristics only to preserve pre-production historical report behavior.