TenantAtlas/specs/189-portfolio-triage-review-state/plan.md
ahmido 2f45ff5a84 feat: add portfolio triage review state tracking (#220)
## Summary
- add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking
- surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview
- extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows
- suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged

## Validation
- targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior
- code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`

## Notes
- full suite was not re-run in this final step
- branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #220
2026-04-10 21:35:17 +00:00

283 lines
27 KiB
Markdown

# Implementation Plan: Portfolio Triage Review State and Operator Progress
**Branch**: `189-portfolio-triage-review-state` | **Date**: 2026-04-10 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Add one lightweight persisted triage-review record per workspace, tenant, and concern family so existing workspace overview, tenant registry triage, and tenant dashboard arrival surfaces can show operator progress without changing posture truth. The implementation will reuse existing backup-health and recovery-evidence resolvers, existing portfolio-arrival context, existing BadgeCatalog and UiEnforcement patterns, and one batch-loaded review-state resolver so the feature stays narrow, query-bounded, RBAC-safe, and clearly separate from formal TenantReview and ReviewPack workflows.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `PortfolioArrivalContext`, `TenantBackupHealthResolver`, `RestoreSafetyResolver`, `BadgeCatalog`, `UiEnforcement`, and `AuditRecorder` patterns
**Storage**: PostgreSQL via Laravel Eloquent with one new table `tenant_triage_reviews` and no new external caches or background stores
**Testing**: Pest 4 unit and feature tests, Filament or Livewire surface tests, focused RBAC regressions, run through Laravel Sail
**Target Platform**: Laravel monolith web application in Sail locally and containerized Linux deployment in staging and production
**Project Type**: web application
**Performance Goals**: Keep review-state mutations DB-only and under the no-`OperationRun` threshold, batch-load review state for tenant-registry and workspace-overview affected sets, reuse existing posture batch resolvers, and avoid new N+1 patterns on `/admin`, `/admin/tenants`, or `/admin/t/{tenant}`
**Constraints**: Preserve posture truth as primary; keep review state workspace-shared and concern-family-specific; no Graph calls; no `OperationRun`; no new formal review or workflow engine; no notes, assignments, or SLA logic; RBAC must keep non-members at `404` and members without capability at `403`; mutations must communicate `TenantPilot only` scope, show a bounded pre-execution preview plus explicit confirmation, and remain reversible
**Scale/Scope**: One new persisted model and migration, one small fingerprint helper, one batch resolver, one narrow mutation service, one new badge domain, and additive changes across three existing operator surfaces and two current concern families (`backup_health`, `recovery_evidence`)
## Constitution Check
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
| Principle | Pre-Research | Post-Design | Notes |
|-----------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | Backup-health and recovery-evidence remain the source of current concern truth; the new table stores only operator progress truth. |
| Read/write separation | PASS | PASS | The feature adds only TenantPilot-internal progress writes. Each mutation stays DB-only and sub-2-second, but still uses a bounded pre-execution preview plus explicit confirmation, server-side authorization, mutation-scope copy, audit logging, and focused tests. |
| Graph contract path | N/A | N/A | No Microsoft Graph calls or contract-registry changes are required. |
| Deterministic capabilities | PASS | PASS | A new capability constant can be added to the canonical registry and enforced through existing capability resolvers and UI helpers. |
| Workspace + tenant isolation | PASS | PASS | Review-state reads and writes are always bound to the active workspace and tenant scope. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`; in-scope members without mutation capability receive `403`; server-side authorization remains authoritative. |
| Run observability / Ops-UX | PASS | PASS | Review-state mutations are DB-only and below the `OperationRun` threshold; no remote or queued work is introduced. |
| Data minimization | PASS | PASS | The persisted snapshot stays bounded to stable concern state, reason, and small diagnostic keys; no notes or rich evidence are stored. |
| Proportionality / no premature abstraction | PASS WITH JUSTIFIED ABSTRACTION | PASS WITH JUSTIFIED ABSTRACTION | One new persisted model plus one fingerprint and state resolver are justified because request-scoped posture truth cannot persist operator progress across time. No broader workflow framework is added. |
| Persisted truth / behavioral state | PASS | PASS | The new table represents independent product truth with its own lifecycle. Only behaviorally meaningful manual states are persisted; `not_reviewed` and `changed_since_review` remain derived. |
| UI semantics / few layers | PASS | PASS | The design adds one narrow review-state semantic family and reuses existing domain truth and central badge mappings rather than introducing a presentation framework. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The plan stays inside existing Filament v5 and Livewire v4 conventions. |
| Provider registration location | PASS | PASS | No panel or provider changes are required. Provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced. Existing impacted resources already satisfy the rule: `TenantResource` has a view page and tenant drilldown pages already exist. |
| Destructive action safety | PASS | PASS | No destructive action is added. `Mark reviewed` and `Mark follow-up needed` are reversible TenantPilot-only progress writes, not destructive mutations. |
| Asset strategy | PASS | PASS | No new assets are introduced. Existing `filament:assets` deployment behavior remains unchanged. |
| Filament-native UI / Action Surface Contract | PASS | PASS | The tenant registry keeps one primary inspect model with review-state mutations in overflow, and the tenant dashboard continuity block remains additive. |
| Filament UX-001 | PASS | PASS | No create or edit layout changes are introduced; list filters, badges, and continuity sections remain within existing page structures. |
| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan adds focused tests for business consequences: persistence, derivation, stale detection, counts, and authorization, not thin presentation layers alone. |
## Phase 0 Research
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/research.md`.
Key decisions:
- Persist triage-review state in a dedicated `tenant_triage_reviews` table instead of reusing `TenantReview` or `ReviewPack` governance artifacts.
- Store only the manual states `reviewed` and `follow_up_needed`; derive `not_reviewed` and `changed_since_review` at read time.
- Use one active row per workspace, tenant, and concern family, resolving prior rows on superseding writes instead of overwriting them in place.
- Compute stable concern fingerprints from existing backup-health and recovery-evidence resolver outputs rather than inventing a second concern model.
- Batch-load active review records alongside existing posture batch resolvers so `/admin` and `/admin/tenants` stay query-bounded.
- Reuse `TenantResource::portfolioConcernPriority()` as the canonical highest-priority selector whenever a mixed registry slice needs one review-state family to render or mutate.
- Use a bounded pre-execution preview and explicit confirmation on dashboard and registry mutation actions instead of introducing a separate remote dry-run pipeline for this local DB write.
- Reuse existing BadgeCatalog, UiEnforcement, PortfolioArrivalContext, and tenant triage fixtures so the UI stays consistent with recent portfolio-triage specs.
- Record lightweight audit entries for progress mutations without introducing an operator-facing audit timeline.
## Phase 1 Design
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/189-portfolio-triage-review-state/`:
- `data-model.md`: persisted triage-review entity, derived state precedence, fingerprint rules, and progress-summary read model
- `contracts/portfolio-triage-review-state.logical.openapi.yaml`: internal logical contract for current-set summaries, registry review-state rendering, and triage-review mutations
- `quickstart.md`: implementation and verification workflow
Design decisions:
- The new table stores only independent operator-progress truth. Current concern posture remains sourced from `TenantBackupHealthResolver` and `RestoreSafetyResolver`.
- The mutation path uses one narrow service that resolves any prior active record, inserts the new active record, writes one bounded audit event, and is invoked only after a surface-level preview plus explicit confirmation.
- Review-state resolution is batch-based and concern-family-aware. The resolver never merges backup-health and recovery-evidence into a single global tenant review state, and mixed registry slices reuse the existing `TenantResource::portfolioConcernPriority()` rules when one family must be chosen.
- Workspace progress summaries consume the same batch resolver output as tenant-registry rows so counts and badges are derived from one source.
- The tenant dashboard arrival continuity block remains the only inline mutation surface; registry mutations stay in overflow to preserve the existing action-surface contract, and generic tenant browsing sessions suppress queue-like triage-review mutation language entirely.
## Project Structure
### Documentation (this feature)
```text
specs/189-portfolio-triage-review-state/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── portfolio-triage-review-state.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/
│ │ │ ├── TenantDashboard.php
│ │ │ └── WorkspaceOverview.php
│ │ ├── Resources/
│ │ │ ├── TenantResource.php
│ │ │ └── TenantResource/
│ │ │ └── Pages/ListTenants.php
│ │ └── Widgets/
│ │ ├── Workspace/
│ │ │ ├── WorkspaceNeedsAttention.php
│ │ │ └── WorkspaceSummaryStats.php
│ │ └── Tenant/
│ │ └── TenantTriageArrivalContinuity.php
│ ├── Models/
│ │ └── TenantTriageReview.php
│ ├── Services/
│ │ ├── Audit/AuditRecorder.php
│ │ └── PortfolioTriage/TenantTriageReviewService.php
│ ├── Support/
│ │ ├── Audit/AuditActionId.php
│ │ ├── Auth/Capabilities.php
│ │ ├── Badges/
│ │ │ ├── BadgeDomain.php
│ │ │ └── Domains/TenantTriageReviewStateBadge.php
│ │ ├── PortfolioTriage/
│ │ │ ├── PortfolioArrivalContext.php
│ │ │ ├── PortfolioArrivalContextResolver.php
│ │ │ ├── TenantTriageReviewFingerprint.php
│ │ │ └── TenantTriageReviewStateResolver.php
│ │ ├── Rbac/UiEnforcement.php
│ │ └── Workspaces/WorkspaceOverviewBuilder.php
│ └── database/
│ ├── factories/TenantTriageReviewFactory.php
│ └── migrations/*_create_tenant_triage_reviews_table.php
└── tests/
├── Feature/
│ ├── Concerns/BuildsPortfolioTriageFixtures.php
│ ├── Filament/
│ │ ├── TenantDashboardArrivalContextTest.php
│ │ ├── TenantRegistryRecoveryTriageTest.php
│ │ ├── TenantRegistryTriageReviewStateTest.php
│ │ └── WorkspaceOverviewTriageReviewProgressTest.php
│ └── Rbac/
│ └── TriageReviewStateAuthorizationTest.php
└── Unit/
└── Support/PortfolioTriage/
├── TenantTriageReviewFingerprintTest.php
└── TenantTriageReviewStateResolverTest.php
```
**Structure Decision**: Keep the existing Laravel monolith layout under `apps/platform`. Add one narrow persisted model and migration, one small service plus resolver and fingerprint helper in the existing `PortfolioTriage` namespace, one badge domain, and additive tests beside the already-established portfolio-triage suites instead of creating a broader workflow subsystem or new base directories.
## Implementation Strategy
### Phase A — Persist Lightweight Triage-Review Truth
**Goal**: Introduce the minimal table and model needed to persist workspace-shared operator progress.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/database/migrations/*_create_tenant_triage_reviews_table.php` | Add the `tenant_triage_reviews` table with `workspace_id`, `tenant_id`, `concern_family`, `current_state`, `reviewed_at`, `reviewed_by_user_id`, `review_fingerprint`, `review_snapshot`, `last_seen_matching_at`, `resolved_at`, timestamps, foreign keys, and a partial unique index for active rows in PostgreSQL |
| A.2 | `apps/platform/app/Models/TenantTriageReview.php` | Add the Eloquent model, casts, relationships to workspace, tenant, and reviewing user, plus active or resolved query scopes |
| A.3 | `apps/platform/database/factories/TenantTriageReviewFactory.php` | Add factory states for `reviewed`, `follow_up_needed`, active, resolved, and changed-fingerprint scenarios used by feature and unit tests |
### Phase B — Resolve Stable Fingerprints And Derived Review State
**Goal**: Combine current concern truth with active review records without creating per-row query fanout or a second concern model.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewFingerprint.php` | Add deterministic fingerprint generation for `backup_health` and `recovery_evidence` from stable resolver outputs and concern-family-safe reason keys |
| B.2 | `apps/platform/app/Support/PortfolioTriage/TenantTriageReviewStateResolver.php` | Add batch loading for active review rows keyed by workspace, tenant, and concern family; derive `not_reviewed`, `reviewed`, `follow_up_needed`, and `changed_since_review`; expose current-set progress counts |
| B.3 | Existing batch posture resolvers and portfolio helpers | Reuse `TenantBackupHealthResolver`, `RestoreSafetyResolver`, and `PortfolioArrivalContextResolver` outputs as the sole inputs to fingerprinting and family focus rather than duplicating concern logic |
### Phase C — Add A Narrow Mutation Service With Audit And RBAC
**Goal**: Provide one canonical write path for `Mark reviewed` and `Mark follow-up needed`.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php` | Add service methods to resolve any prior active row, insert a new active row, bound the snapshot payload, and return the new derived state |
| C.2 | `apps/platform/app/Support/Auth/Capabilities.php` | Add one canonical capability for triage-review mutation, scoped to tenant or workspace triage usage according to existing registry patterns |
| C.3 | `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/AuditRecorder.php` usage | Add lightweight audit action IDs and record bounded audit entries for the two mutation verbs without adding a new operator-facing audit timeline |
| C.4 | `apps/platform/app/Support/Rbac/UiEnforcement.php` integration points | Reuse visible-but-disabled RBAC gating for members without capability and preserve server-side 404 or 403 semantics on action execution |
| C.5 | `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php`, and `apps/platform/resources/views/filament/widgets/tenant/triage-arrival-continuity.blade.php` | Add a bounded pre-execution preview and explicit confirmation showing concern family, current review state, target manual state, and `TenantPilot only` scope before invoking the mutation service |
### Phase D — Bind Review State Into Existing Operator Surfaces
**Goal**: Show current review state and progress exactly where portfolio triage already lives.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Workspaces/WorkspaceOverviewBuilder.php` | Extend current affected-set summary building so overview widgets receive review-state progress counts derived from the same visible tenant population |
| D.2 | `apps/platform/app/Filament/Widgets/Workspace/WorkspaceSummaryStats.php` and `apps/platform/app/Filament/Widgets/Workspace/WorkspaceNeedsAttention.php` | Render additive current-set progress chips or summary text without changing existing posture truth or drilldown semantics |
| D.3 | `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` | Add a review-state column, all four review-state filters, and overflow actions for `Mark reviewed` and `Mark follow-up needed`, keeping full-row click and the existing `openTenant` shortcut intact and reusing `TenantResource::portfolioConcernPriority()` for mixed-slice family selection |
| D.4 | `apps/platform/app/Filament/Widgets/Tenant/TenantTriageArrivalContinuity.php` and `apps/platform/app/Filament/Pages/TenantDashboard.php` | Show the resolved review state for the current arrival concern family, expose the same two preview-confirmed mutation actions inside the existing continuity block only when valid triage context exists, and suppress queue-like review-state mutation language in generic tenant browsing sessions |
| D.5 | `apps/platform/app/Support/Badges/BadgeDomain.php` and `apps/platform/app/Support/Badges/Domains/TenantTriageReviewStateBadge.php` | Register centralized badge semantics for `Not reviewed`, `Reviewed`, `Follow-up needed`, and `Changed since review` so the same labels render consistently across overview, registry, and dashboard |
### Phase E — Regression Protection And Verification
**Goal**: Prove persistence, derivation, and authorization without regressing portfolio-triage semantics.
| Step | File | Change |
|------|------|--------|
| E.1 | `apps/platform/tests/Feature/Concerns/BuildsPortfolioTriageFixtures.php` | Extend existing fixtures with review-state records and changed-fingerprint scenarios so new tests reuse the current portfolio-triage setup |
| E.2 | `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewFingerprintTest.php` | Cover stable fingerprint generation, ignored volatile fields, and concern-family separation |
| E.3 | `apps/platform/tests/Unit/Support/PortfolioTriage/TenantTriageReviewStateResolverTest.php` | Cover state precedence, current-set exclusion, batch derivation, and changed-since-review behavior |
| E.4 | `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php` and `apps/platform/tests/Feature/Filament/TenantRegistryRecoveryTriageTest.php` | Cover registry review-state badges, filters, overflow actions, and no-conflation with posture truth |
| E.5 | `apps/platform/tests/Feature/Filament/TenantDashboardArrivalContextTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceOverviewTriageReviewProgressTest.php` | Cover dashboard continuity actions, overview progress counts, current-set-only semantics, and changed-since-review visibility |
| E.6 | `apps/platform/tests/Feature/Rbac/TriageReviewStateAuthorizationTest.php` | Cover non-member `404`, member-without-capability `403`, visible-but-disabled UI, and allowed mutation success |
| E.7 | Focused Sail and Pint runs | Run the minimal verification pack for unit, feature, and RBAC suites plus `pint --dirty --format agent` |
## Key Design Decisions
### D-001 — Persist only manual review intent, derive everything else
The table stores only `reviewed` and `follow_up_needed`. `not_reviewed` and `changed_since_review` remain derived from the presence of an active row plus fingerprint comparison. This keeps the persisted state family narrow and behaviorally meaningful.
### D-002 — Reuse current concern truth; do not create a second concern model
`TenantBackupHealthResolver`, `RestoreSafetyResolver`, and existing portfolio-arrival context already know which family and state currently matter. Fingerprints must be built from those stable outputs, not from new parallel concern DTOs.
### D-003 — Keep one active row per workspace, tenant, and concern family
Superseding writes resolve the previous active row and insert a new one. This preserves lightweight continuity without requiring a formal review timeline or a collaboration engine.
### D-004 — Use existing portfolio-triage surfaces instead of adding a queue page
Workspace overview, tenant registry, and the tenant dashboard continuity block already form the operator flow. Adding a separate triage-review page would add IA weight without solving a new problem.
### D-005 — Use central badge and RBAC helpers, not local status language or ad hoc authorization
The review-state labels must be rendered through the same badge infrastructure used elsewhere, and mutation actions must be gated through the canonical capability registry plus `UiEnforcement` so the feature does not scatter raw strings or local auth logic.
### D-006 — Keep the feature lightweight but still auditable
No `OperationRun`, no external writes, and no audit timeline are needed, but the two progress mutations still use bounded preview-and-confirmation UI and emit bounded `AuditLog` entries so the write path remains traceable under the constitution's write-safety rules.
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Fingerprint rules are too sensitive and overproduce `Changed since review` | High | Medium | Restrict fingerprint inputs to stable state, reason-code, and posture keys; cover false-positive cases in unit tests. |
| Fingerprint rules are too weak and leave stale `Reviewed` badges visible | High | Medium | Define family-specific stable keys from existing resolvers and cover state-transition cases in resolver tests. |
| Review-state loading introduces N+1 behavior on `/admin` or `/admin/tenants` | High | Medium | Batch-load active rows keyed by tenant and concern family, reuse current posture batch resolvers, and add query-shape regressions. |
| Backup and recovery review state become conflated on mixed rows | High | Medium | Keep explicit concern-family focus in resolver output and registry rendering; add family-separation tests. |
| Lightweight writes are mistaken for formal review completion | Medium | Medium | Keep vocabulary narrow (`Review state`, `Mark reviewed`, `Changed since review`), render posture truth separately, and avoid formal review nouns. |
| Audit logging grows into a new workflow surface | Low | Low | Limit audit to backend `AuditLog` recording only and explicitly keep timeline UI and governance artifacts out of scope. |
## Test Strategy
- Add focused unit tests for fingerprint generation, ignored volatile inputs, active-row precedence, current-set exclusion, and concern-family isolation.
- Extend portfolio-triage fixture builders so review-state scenarios can be exercised alongside existing backup and recovery posture scenarios.
- Add Filament feature coverage for tenant-registry column rendering, all four review-state filters, mixed-family highest-priority selection, preview-confirmed overflow actions, and no-conflation with backup or recovery truth.
- Extend tenant-dashboard arrival-context tests so the continuity block shows review state, executes the two allowed mutations only when triage context exists, and stays suppressed for generic tenant browsing sessions.
- Add workspace-overview progress-summary tests so counts are derived only from the current visible affected set and remain separate per concern family.
- Add RBAC tests for non-member `404`, member-without-capability `403`, and successful mutation for an authorized operator.
- Add audit assertions for bounded `AuditLog` entries on both mutation verbs without requiring an operator-facing timeline.
- No new relation managers or destructive flows are introduced. Covered Filament surfaces are `WorkspaceOverview`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantResource`, `ListTenants`, `TenantDashboard`, and `TenantTriageArrivalContinuity`.
- Run the minimum focused Sail pack before implementation sign-off: migration-aware unit tests, new and extended Filament feature tests, RBAC tests, and `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| New persisted `tenant_triage_reviews` table | Request-scoped posture and arrival context cannot preserve operator progress across sessions or team handoff | Recomputing from current posture alone cannot answer whether someone already reviewed the current concern |
| New fingerprint helper and batch resolver | The feature must compare current concern truth to the last reviewed concern without N+1 behavior and without duplicating posture logic | Ad hoc per-surface comparisons would repeat concern logic, drift across overview or registry surfaces, and increase query fanout |
| Bounded preview and confirmation for DB-only review-state writes | The constitution still requires write safety even though this mutation is a reversible TenantPilot-only local write | A blind click would violate the constitution, while a fuller remote dry-run pipeline would overfit a sub-2-second DB-only action |
## Proportionality Review
- **Current operator problem**: Operators can already identify affected tenants and open them with context, but they still cannot track what has been checked, what still needs follow-up, or what became relevant again after a prior review.
- **Existing structure is insufficient because**: Existing workspace overview, tenant registry, and arrival context are all read-first and request-scoped. None of them persists lightweight review progress across time.
- **Narrowest correct implementation**: Add one lightweight persisted triage-review table, one stable fingerprint helper, one batch resolver, and additive bindings into existing overview, registry, and dashboard surfaces.
- **Ownership cost created**: One migration, one new model and factory, one small service plus resolver, one badge domain, small action bindings, and focused regression coverage for persistence, derivation, authorization, and progress counts.
- **Alternative intentionally rejected**: Reusing `TenantReview` or `ReviewPack`, adding notes or assignments, or introducing a full workflow queue were rejected because they create governance and collaboration weight beyond the current operational-triage need.
- **Release truth**: Current-release truth. The feature closes an active workflow gap in the shipped portfolio-triage experience rather than preparing a future governance layer.