Compare commits

...

4 Commits

Author SHA1 Message Date
445464afdc docs: add website working contract (#247)
Some checks failed
Main Confidence / confidence (push) Failing after 46s
## Summary
- add a repo-truth-based website working contract for `apps/website`
- link the new contract from the workspace README
- document the current minimal website/platform coordination boundaries

## Testing
- not run (documentation-only changes)

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #247
2026-04-18 14:17:46 +00:00
3bdd27f747 feat: finalize global shell context contract (#246)
Some checks failed
Main Confidence / confidence (push) Failing after 43s
## Summary
- consolidate workspace and tenant shell resolution behind a canonical resolved shell context
- align workspace switching, tenant selection, and tenant clearing with the new recovery and fallback rules
- add focused Pest coverage for shell resolution and update root dev orchestration so platform Vite starts correctly from repo-root commands

## Testing
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/HeaderContextBarTest.php
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/GlobalContextShellContractTest.php
- manual integrated-browser smoke for tenant-bound shell actions and context recovery flows
- validated corepack pnpm build:platform, corepack pnpm dev:platform, corepack pnpm dev:website, and corepack pnpm dev

## Notes
- Livewire v4 / Filament v5 remain unchanged and provider registration stays in bootstrap/providers.php
- no new globally searchable resources or destructive Filament actions were introduced

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #246
2026-04-18 14:00:49 +00:00
ea9ef9cb38 docs: add Spec 212 test authoring guardrails (#245)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
## Summary

- add Spec 212 planning artifacts for test authoring constitution and review guardrails
- expand `TEST-GOV-001` and sync the SpecKit spec/plan/tasks/checklist templates plus contributor guidance
- define the canonical review checklist outcomes and record low-impact and higher-cost validation examples

## Validation

- docs/workflow only; no runtime Pest or Sail test lanes were run
- validation is recorded in `specs/212-test-authoring-guardrails/spec.md` and `specs/212-test-authoring-guardrails/quickstart.md`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #245
2026-04-18 10:08:00 +00:00
81a07a41e4 feat: implement runtime trend recalibration reporting (#244)
Some checks failed
Main Confidence / confidence (push) Failing after 46s
## Summary
- implement Spec 211 runtime trend reporting with bounded lane history, drift classification, hotspot trend output, and recalibration evidence handling
- extend the repo-truth governance seams and workflow wrappers for comparable-bundle hydration, trend artifact publication, and contract-backed reporting
- add the Spec 211 planning artifacts, data model, quickstart, tasks, and repository contract documents

## Validation
- parsed `specs/211-runtime-trend-recalibration/contracts/test-runtime-trend-history.schema.json`
- parsed `specs/211-runtime-trend-recalibration/contracts/test-runtime-trend.logical.openapi.yaml`
- re-ran cross-artifact consistency analysis for the Spec 211 artifact set until no material findings remained
- no application test suite was re-run as part of this final commit/push/PR step

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #244
2026-04-18 07:36:05 +00:00
88 changed files with 9727 additions and 366 deletions

View File

@ -5,6 +5,10 @@ on:
schedule:
- cron: '43 4 * * 1-5'
permissions:
actions: read
contents: read
jobs:
browser:
if: ${{ github.event_name != 'schedule' || vars.TENANTATLAS_ENABLE_BROWSER_SCHEDULE == '1' }}
@ -53,7 +57,9 @@ jobs:
- name: Refresh Browser report
if: always()
run: ./scripts/platform-test-report browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history
- name: Stage Browser artifacts
if: always()
@ -71,4 +77,4 @@ jobs:
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop
./vendor/bin/sail stop

View File

@ -5,6 +5,10 @@ on:
schedule:
- cron: '17 4 * * 1-5'
permissions:
actions: read
contents: read
jobs:
heavy-governance:
if: ${{ github.event_name != 'schedule' || vars.TENANTATLAS_ENABLE_HEAVY_GOVERNANCE_SCHEDULE == '1' }}
@ -53,7 +57,9 @@ jobs:
- name: Refresh Heavy Governance report
if: always()
run: ./scripts/platform-test-report heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history
- name: Stage Heavy Governance artifacts
if: always()
@ -71,4 +77,4 @@ jobs:
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop
./vendor/bin/sail stop

View File

@ -5,6 +5,10 @@ on:
branches:
- dev
permissions:
actions: read
contents: read
jobs:
confidence:
runs-on: ubuntu-latest
@ -41,7 +45,9 @@ jobs:
- name: Refresh Confidence report
if: always()
run: ./scripts/platform-test-report confidence --workflow-id=main-confidence --trigger-class=mainline-push
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report confidence --workflow-id=main-confidence --trigger-class=mainline-push --fetch-latest-history
- name: Stage Confidence artifacts
if: always()
@ -59,4 +65,4 @@ jobs:
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop
./vendor/bin/sail stop

View File

@ -7,6 +7,10 @@ on:
- reopened
- synchronize
permissions:
actions: read
contents: read
jobs:
fast-feedback:
runs-on: ubuntu-latest
@ -43,7 +47,9 @@ jobs:
- name: Refresh Fast Feedback report
if: always()
run: ./scripts/platform-test-report fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request
env:
TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: ./scripts/platform-test-report fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request --fetch-latest-history
- name: Stage Fast Feedback artifacts
if: always()
@ -61,4 +67,4 @@ jobs:
if: always()
run: |
cd apps/platform
./vendor/bin/sail stop
./vendor/bin/sail stop

View File

@ -7,6 +7,7 @@ ## Relocation override
- Human-facing commands should use `cd apps/platform && ...`.
- Repo-root tooling may delegate via `./scripts/platform-sail` when it cannot set a nested working directory.
- Repo-root JavaScript orchestration uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
- If any generated technology note below conflicts with the current repo, trust `apps/platform/composer.json`, `apps/platform/package.json`, and the live Laravel application metadata over stale generated entries.
@ -198,6 +199,12 @@ ## Active Technologies
- SQLite `:memory:` for the default test environment, mixed database strategy for some heavy-governance families as declared in `TestLaneManifest`, and existing lane artifacts under the app-root contract path `storage/logs/test-lanes` (209-heavy-governance-cost)
- PHP 8.4.15 for repo-truth test governance, Bash for repo-root wrappers, and GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/` + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams (210-ci-matrix-budget-enforcement)
- SQLite `:memory:` for default lane execution, filesystem artifacts under the app-root contract path `storage/logs/test-lanes`, checked-in workflow YAML under `.gitea/workflows/`, and no new product database persistence (210-ci-matrix-budget-enforcement)
- PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams (211-runtime-trend-recalibration)
- SQLite `:memory:` for lane execution, filesystem artifacts under `apps/platform/storage/logs/test-lanes`, staged CI bundles under `.gitea-artifacts/<workflow-profile>`, bounded derived trend/history artifacts adjacent to current lane artifacts, and no new product database persistence (211-runtime-trend-recalibration)
- Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary (212-test-authoring-guardrails)
- Repository-owned markdown and contract artifacts under `.specify/`, `specs/212-test-authoring-guardrails/`, and root documentation files; no product database persistence (212-test-authoring-guardrails)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext` (199-global-context-shell-contract)
- PostgreSQL unchanged plus existing Laravel session keys `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`; no schema change planned (199-global-context-shell-contract)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -232,8 +239,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 210-ci-matrix-budget-enforcement: Added PHP 8.4.15 for repo-truth test governance, Bash for repo-root wrappers, and GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/` + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
- 209-heavy-governance-cost: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
- 208-heavy-suite-segmentation: Added PHP 8.4.15 + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail
- 199-global-context-shell-contract: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext`
- 212-test-authoring-guardrails: Added Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary
- 211-runtime-trend-recalibration: Added PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts + Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -293,6 +293,7 @@ ## Application Structure & Architecture
## Workspace Commands
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
## Frontend Bundling

View File

@ -10,6 +10,26 @@ ## Important
- `plan.md`
- `tasks.md`
- `checklists/requirements.md`
- Runtime-changing work MUST carry testing/lane/runtime impact through the active `spec.md`, `plan.md`, and `tasks.md`; lane upkeep belongs to the feature, not to a later cleanup pass.
- Runtime-changing or test-affecting work MUST carry actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, `Browser`), affected lanes, fixture/default cost risks, heavy-family changes, escalation decisions, and minimal validation commands through the active `spec.md`, `plan.md`, and `tasks.md`.
- Review-oriented checklists MUST surface lane fit, hidden defaults, heavy-family visibility, and runtime-budget follow-up before merge; lane upkeep belongs to the feature, not to a later cleanup pass.
## Review Entry Point
Use the active feature's `spec.md`, `plan.md`, and `tasks.md` together with the generated checklist based on `.specify/templates/checklist-template.md`.
1. Confirm the spec names the affected validation lane(s) or a deliberate `N/A`, the test family impact, setup-cost impact, reviewer handoff, and any escalation outcome.
2. Confirm the plan turns that into changed test types, narrowest proving commands, helper/default widening checks, and the note target for budget or trend drift.
3. Apply the checklist and end with one explicit outcome: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
## Low-Impact Rule
- Docs-only or template-only work may answer `N/A` or `none`.
- Do not force fake lane prose when no runtime or suite impact exists.
## Escalation Rule
- Use `document-in-feature` for contained cost or drift that belongs in the active feature.
- Use `follow-up-spec` only for recurring pain or structural lane or family changes.
- Use `reject-or-split` when hidden test cost or wrong-lane scope is still unresolved.
The files `.specify/spec.md`, `.specify/plan.md`, `.specify/tasks.md` may exist as legacy references only.

View File

@ -1,29 +1,33 @@
<!--
Sync Impact Report
- Version change: 2.3.0 -> 2.4.0
- Version change: 2.4.0 -> 2.5.0
- Modified principles:
- Quality Gates: expanded to require narrowest-lane validation and
runtime-drift notes for runtime changes
- Governance review expectations: expanded to make lane/runtime
impact a mandatory part of spec and PR review
- Added sections:
- Test Suite Governance Must Live In The Delivery Workflow
(TEST-GOV-001)
(TEST-GOV-001): expanded into explicit test-impact disclosure,
lane discipline, minimal-fixture defaults, heavy-family visibility,
expensive-default bans, runtime-budget stewardship, review-stop
rules, and escalation triggers
- Governance review expectations: expanded to require test-purpose
classification, explicit runtime-cost review, and visible review
routine coverage in delivery artifacts
- Added sections: None
- Removed sections: None
- Templates requiring updates:
- ✅ .specify/memory/constitution.md
- ✅ .specify/templates/plan-template.md (test-governance planning and
lane-impact checks added)
- ✅ .specify/templates/spec-template.md (mandatory testing/lane/runtime
impact section added)
- ✅ .specify/templates/tasks-template.md (lane classification,
fixture-cost, and runtime-drift task guidance added)
- ✅ .specify/templates/checklist-template.md (runtime checklist note
added)
- ✅ .specify/README.md (SpecKit workflow note added for lane/runtime
ownership)
- ✅ README.md (developer routine updated for test-governance upkeep)
- ✅ .specify/templates/plan-template.md (lane-discipline and
escalation-planning checks expanded)
- ✅ .specify/templates/spec-template.md (test-purpose,
lane-discipline, heavy-family, and escalation prompts expanded)
- ✅ .specify/templates/tasks-template.md (task obligations expanded for
classification, cheap defaults, review-stop rules, and runtime
stewardship)
- ✅ .specify/templates/checklist-template.md (review checklist guidance
expanded for lane fit, heavy risk, and escalation)
- ✅ .specify/README.md (SpecKit workflow expectations expanded for
visible test-governance coverage)
- ✅ README.md (developer workflow guidance expanded for lane
discipline and runtime stewardship)
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present in this repo
- Follow-up TODOs: None
@ -104,10 +108,17 @@ ### Tests Must Protect Business Truth (TEST-TRUTH-001)
### Test Suite Governance Must Live In The Delivery Workflow (TEST-GOV-001)
- Test-suite governance is a standing workflow rule, not an occasional cleanup project.
- Every runtime-changing spec MUST declare the affected validation lane(s), any fixture/helper cost risk, whether it introduces or expands heavy-governance or browser coverage, and whether budget/baseline follow-up is needed.
- Plans MUST choose the narrowest lane mix that proves the change and MUST call out new heavy families, expensive defaults, or CI/runtime drift before implementation starts.
- Tasks and reviews MUST confirm lane classification, keep default fixtures cheap, reject accidental heavy promotion, and record material runtime drift or recalibration work in the active spec or PR.
- Standalone follow-up specs for test governance are reserved for recurring pain or structural lane changes; ordinary recalibration belongs inside normal delivery work.
- Every spec or implementation change that changes runtime behavior, tests, lane mix, or shared test infrastructure MUST state test impact explicitly: affected validation lane(s), actual test purpose classification (`Unit`, `Feature`, `Heavy-Governance`, `Browser`), any new or broader test family, fixture/helper/factory/seed/context cost change, and any budget, baseline, or trend follow-up.
- Docs-only, template-only, or otherwise no-runtime-impact work MAY answer the test-governance prompts with concise `N/A` or `none`, but MUST still make the absence of runtime or suite impact explicit.
- Test classification MUST follow the proving purpose of the change rather than directory names, filenames, or convenience. Fast or narrow lanes MUST NOT silently absorb discovery, surface, workflow, or browser cost that belongs in heavier governance lanes.
- Minimal fixtures and minimal infrastructure are the default. Database, Livewire, Filament, provider setup, workspace or membership context, session state, capability context, and similar expensive dependencies MUST be used only when the asserted behavior requires them.
- Heavy families MUST remain explicit in naming, lane assignment, and review rationale. New or expanded heavy-governance, discovery, surface, broad workflow, or browser families MUST NOT appear accidentally through helper drift, copied setup, or folder placement alone.
- Shared helpers, factories, seeds, and support layers MUST keep expensive context opt-in. Provider, workspace, membership, capability, session, or similar full-context defaults MUST NOT become implicit norms.
- Lane budgets, baselines, and trend reports are engineering constraints. Changes that materially worsen runtime, shift lane semantics, or create heavy cost centers MUST be validated, documented, and escalated when the impact exceeds ordinary feature-local upkeep.
- Reviews MUST stop test drift before merge. Reviewers MUST verify lane fit, test breadth, fixture cost, heavy-family risk, and runtime impact, and MUST treat unnecessary breadth, wrong classification, or hidden cost as merge blockers rather than later CI cleanup.
- Review checklists MUST end with one explicit outcome: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`, so the decision about suite-cost risk is attributable instead of implied.
- New governance cost centers, including new heavy families, new browser coverage, material lane-cost shifts, revived expensive defaults, or budget/baseline-relevant regressions, MUST be documented explicitly. Contained feature-local cases MAY be `document-in-feature`; structural or recurring cases MUST escalate to `follow-up-spec`; unjustified scope or hidden cost MUST resolve as `reject-or-split`.
- These rules MUST stay visible in the spec, plan, task, and review routine. Test governance MUST NOT live only in CI output, wrapper scripts, or tribal knowledge.
### Enterprise Complexity Is Allowed Only Where Risk Demands It (RISK-COMP-001)
- Heavier architecture is explicitly legitimate for workspace or tenant isolation, RBAC and policy enforcement, auditability, immutable history and snapshot truth, queue/job execution legitimacy, provider credential safety, retention/compliance evidence, and operator-critical lifecycle correctness.
@ -1337,11 +1348,12 @@ ### Scope, Compliance, and Review Expectations
- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones.
- Restore semantics changes require: spec update, checklist update (if applicable), and tests proving safety.
- Specs and PRs that introduce new persisted truth, abstractions, states, DTO/presenter layers, or taxonomies MUST include the proportionality review required by BLOAT-001.
- Runtime-changing specs and PRs MUST include testing/lane/runtime impact covering affected lanes, fixture/helper cost changes, any heavy-family expansion, expected budget/baseline effect, and the minimal validation commands.
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
- Specs and PRs that change operator-facing surfaces MUST classify each
affected surface under DECIDE-001 and justify any new Primary
Decision Surface or workflow-first navigation change.
- Reviews MUST reject runtime changes when lane classification is missing, expensive defaults are introduced silently, or material CI/runtime drift is left undocumented.
- Reviews MUST reject runtime or test changes when lane classification is missing, fast-lane work quietly absorbs heavy cost, expensive defaults are introduced silently, or material CI/runtime drift is left undocumented.
- Review and approval MUST favor simplification, replacement, and absorption over additive semantic layering.
- Future-release preparation alone is not sufficient justification for new persistence or frameworkization unless security, tenant isolation, auditability, compliance evidence, or queue correctness already require it.
@ -1355,4 +1367,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.4.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-17
**Version**: 2.5.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-18

View File

@ -5,37 +5,39 @@ # [CHECKLIST TYPE] Checklist: [FEATURE NAME]
**Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
If the checklist covers runtime behavior changes, include lane classification, fixture-cost review, heavy-family justification, minimal validation commands, and any budget/baseline follow-up checks.
If the checklist covers runtime behavior or test-surface changes, use it to reach one explicit outcome: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
Low-impact docs-only or template-only work may mark runtime-only checks `N/A`, but should still leave one explicit outcome.
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The /speckit.checklist command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->
## Lane Fit
## [Category 1]
- [ ] CHK001 The chosen validation lane is the narrowest lane or lane mix that proves the change.
- [ ] CHK002 The test stays in the smallest honest family (`Unit`, `Feature`, `Heavy-Governance`, `Browser`) and does not hide broader purpose behind a narrow label.
- [ ] CHK001 First checklist item with clear action
- [ ] CHK002 Second checklist item
- [ ] CHK003 Third checklist item
## Breadth And Cost
## [Category 2]
- [ ] CHK003 The changed or added test is no broader than the behavior it proves.
- [ ] CHK004 Any database, Livewire, Filament, or browser surface is justified over a narrower alternative.
- [ ] CHK005 Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is explicit and locally justified.
- [ ] CHK004 Another category item
- [ ] CHK005 Item with specific criteria
- [ ] CHK006 Final item in this category
## Validation And Drift
- [ ] CHK006 The minimal reviewer validation command is written explicitly and matches the declared lane.
- [ ] CHK007 Any material budget, baseline, trend, or runtime-drift note is recorded in the active spec or PR.
## Escalation Outcome
- [ ] CHK008 One explicit outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- [ ] CHK009 New heavy families, new browser coverage, revived expensive defaults, or material lane-cost shifts are not left implicit.
## Notes
- `keep`: current lane, family, and setup are justified.
- `split`: scope is valid, but the test or helper spread should be narrowed before merge.
- `document-in-feature`: the change is acceptable, but the cost or drift must be recorded in the active spec or PR.
- `follow-up-spec`: recurring pain or structural lane or family changes need dedicated governance work.
- `reject-or-split`: hidden cost, wrong lane, or unjustified breadth blocks merge as proposed.
- Check items off as completed: `[x]`
- Add comments or findings inline
- Link to relevant resources or documentation
- Items are numbered sequentially for easy reference
- Reviewer-facing runtime checklists SHOULD stop merge when lane fit, hidden cost, heavy-family drift, or escalation handling is unclear.

View File

@ -49,7 +49,7 @@ ## Constitution Check
- Ops-UX system runs: initiator-null runs emit no terminal DB notification; audit remains via Monitoring; tenant-wide alerting goes through Alerts (not OperationRun notifications)
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
- Test governance (TEST-GOV-001): affected lanes, fixture/helper cost risks, heavy-family changes, and any budget/baseline follow-up are explicit; the narrowest proving lane is planned
- Test governance (TEST-GOV-001): actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost risks, heavy-family visibility, review-stop points, reviewer handoff, and any budget/baseline/trend follow-up are explicit; the narrowest proving lane mix is planned and any structural cost change has an escalation path
- Proportionality (PROP-001): any new structure, layer, persisted truth, or semantic machinery is justified by current release truth, current operator workflow, and why a narrower solution is insufficient
- No premature abstraction (ABSTR-001): no new factories, registries, resolvers, strategy systems, interfaces, type registries, or orchestration pipelines before at least 2 real concrete cases exist, unless security, tenant isolation, auditability, compliance evidence, or queue correctness require it now
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
@ -92,13 +92,19 @@ ## Constitution Check
## Test Governance Check
> **Fill for any runtime-changing feature. Docs-only or template-only work may state `N/A`.**
> **Fill for any runtime-changing or test-affecting feature. Docs-only or template-only work may state concise `N/A` or `none`.**
- **Test purpose / classification by changed surface**: [Unit / Feature / Heavy-Governance / Browser / N/A]
- **Affected validation lanes**: [fast-feedback / confidence / heavy-governance / browser / profiling / junit / N/A]
- **Why this lane mix is the narrowest sufficient proof**: [Why the chosen classification and lanes fit the actual proving purpose]
- **Narrowest proving command(s)**: [Exact commands reviewers should run before merge]
- **Fixture / helper cost risks**: [none / describe]
- **Heavy-family additions or promotions**: [none / describe]
- **Fixture / helper / factory / seed / context cost risks**: [none / describe]
- **Expensive defaults or shared helper growth introduced?**: [no / describe explicit opt-in path]
- **Heavy-family additions, promotions, or visibility changes**: [none / describe]
- **Closing validation and reviewer handoff**: [What must be re-run, what reviewers should verify, and what exact proof command they should rely on]
- **Budget / baseline / trend follow-up**: [none / describe]
- **Review-stop questions**: [lane fit / breadth / hidden cost / heavy-family risk / escalation]
- **Escalation path**: [none / document-in-feature / follow-up-spec / reject-or-split]
- **Why no dedicated follow-up spec is needed**: [Routine upkeep stays inside this feature unless recurring pain or structural lane changes justify a separate spec]
## Project Structure

View File

@ -90,14 +90,17 @@ ## Proportionality Review *(mandatory when structural complexity is introduced)*
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
For docs-only changes, state `N/A` for each field.
For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name.
- **Test purpose / classification**: [Unit / Feature / Heavy-Governance / Browser / N/A]
- **Validation lane(s)**: [fast-feedback / confidence / heavy-governance / browser / profiling / junit / N/A]
- **Why these lanes are sufficient**: [Why the narrowest listed lane(s) prove the change]
- **Why this classification and these lanes are sufficient**: [Why the narrowest listed lane(s) and chosen test type prove the change]
- **New or expanded test families**: [none / describe]
- **Fixture / helper cost impact**: [none / describe new defaults, factories, seeds, helpers, browser setup, etc.]
- **Heavy coverage justification**: [none / explain any heavy-governance or browser addition]
- **Fixture / helper cost impact**: [none / describe new defaults, factories, seeds, helpers, browser setup, provider setup, workspace or membership context, session state, etc.]
- **Heavy-family visibility / justification**: [none / explain any heavy-governance or browser addition and how it remains explicit in naming, lane choice, and review]
- **Reviewer handoff**: [What reviewers must confirm about lane fit, hidden cost, heavy-family visibility, and the exact proof command]
- **Budget / baseline / trend impact**: [none / expected drift + follow-up]
- **Escalation needed**: [none / document-in-feature / follow-up-spec / reject-or-split]
- **Planned validation commands**: [Exact minimal commands reviewers should run]
## User Scenarios & Testing *(mandatory)*
@ -188,10 +191,14 @@ ## Requirements *(mandatory)*
or taxonomy/classification system, the Proportionality Review section above is mandatory.
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
- the affected validation lane(s) and why they are the narrowest sufficient proof,
- any new or expanded heavy-governance or browser coverage,
- any fixture, helper, factory, seed, or default setup cost added or avoided,
- any fixture, helper, factory, seed, provider, workspace, membership, session, or default setup cost added or avoided,
- how any heavy family stays explicit rather than becoming accidental default breadth,
- the reviewer handoff for lane fit, hidden-cost checks, and the exact minimal validation commands,
- any expected budget, baseline, or trend impact,
- whether escalation stays inside this feature or resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`,
- and the exact minimal validation commands reviewers should run.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:

View File

@ -10,11 +10,13 @@ # Tasks: [FEATURE NAME]
**Tests**: For runtime behavior changes in this repo, tests are REQUIRED (Pest). Only docs-only changes may omit tests.
Runtime-changing features MUST also include tasks to:
- classify or confirm the affected validation lane(s),
- keep new helpers, factories, and seeds cheap by default or isolate expensive setup behind explicit opt-ins,
- justify any new heavy-governance or browser coverage,
- classify the actual test purpose (`Unit`, `Feature`, `Heavy-Governance`, `Browser`) and confirm the affected validation lane(s),
- keep fast or narrow lanes free of silent discovery, surface, workflow, or browser cost,
- keep new helpers, factories, seeds, providers, session state, and support defaults cheap by default or isolate expensive setup behind explicit opt-ins,
- make any new heavy-governance or browser family explicit in naming, lane assignment, and review notes,
- run the narrowest relevant lane before merge,
- and record budget, baseline, or trend follow-up when runtime cost shifts materially.
- record budget, baseline, or trend follow-up when runtime cost shifts materially,
- and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
**Operations**: If this feature introduces long-running/remote/queued/scheduled work, include tasks to create/reuse and update a
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
@ -129,7 +131,17 @@ # Tasks: [FEATURE NAME]
- and adding tests around business consequences, permissions, lifecycle behavior, isolation, or audit responsibilities rather than thin indirection alone.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
Runtime behavior changes SHOULD include at least one explicit task for lane validation or runtime-impact review so upkeep stays inside the feature instead of becoming separate cleanup.
Runtime behavior or test-surface changes MUST include at least one explicit task for lane validation or runtime-impact review so upkeep stays inside the feature instead of becoming separate cleanup.
## Test Governance Checklist
Include this short checklist in generated task lists for runtime-changing or test-affecting work. Docs-only or template-only work may mark the items `N/A`.
- [ ] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- [ ] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- [ ] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- [ ] Planned validation commands cover the change without pulling in unrelated lane cost.
- [ ] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Format: `[ID] [P?] [Story] Description`

View File

@ -722,6 +722,7 @@ ## Application Structure & Architecture
## Frontend Bundling
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.

View File

@ -560,6 +560,7 @@ ## Application Structure & Architecture
## Frontend Bundling
- Repo-root JavaScript orchestration now uses `corepack pnpm install`, `corepack pnpm dev:platform`, `corepack pnpm dev:website`, `corepack pnpm dev`, `corepack pnpm build:website`, and `corepack pnpm build:platform`.
- `corepack pnpm dev:platform` starts the platform Sail stack and the Laravel panel Vite watcher. `corepack pnpm dev` starts that platform watcher plus the website dev server.
- `apps/website` is a standalone Astro app, not a second Laravel runtime, so Boost MCP remains platform-only.
- If the user doesn't see a platform frontend change reflected in the UI, it could mean they need to run `cd apps/platform && ./vendor/bin/sail pnpm build`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail composer run dev`. Ask them.

View File

@ -12,14 +12,17 @@ ## Multi-App Topology
- repo root: workspace manifests, documentation, scripts, editor tooling, and `docker-compose.yml`
- `./scripts/platform-sail`: platform-only compatibility helper for tooling that cannot set `cwd`
Website-track guardrails for independent evolution live in
[`docs/strategy/website-working-contract.md`](docs/strategy/website-working-contract.md).
## Official Root Commands
- Install workspace-managed JavaScript dependencies: `corepack pnpm install`
- Start the platform stack: `corepack pnpm dev:platform`
- Start the platform stack and Laravel panel Vite watcher: `corepack pnpm dev:platform`
- Start the website dev server: `corepack pnpm dev:website`
- Start platform + website together: `corepack pnpm dev`
- Start platform Vite + website together: `corepack pnpm dev`
- Build the website: `corepack pnpm build:website`
- Build platform frontend assets: `corepack pnpm build:platform`
- Build platform frontend assets inside Sail: `corepack pnpm build:platform`
## App-Local Commands
@ -29,7 +32,7 @@ ### Platform
- Start Sail: `cd apps/platform && ./vendor/bin/sail up -d`
- Generate the app key: `cd apps/platform && ./vendor/bin/sail artisan key:generate`
- Run migrations and seeders: `cd apps/platform && ./vendor/bin/sail artisan migrate --seed`
- Run frontend watch/build inside Sail: `cd apps/platform && ./vendor/bin/sail pnpm dev` or `cd apps/platform && ./vendor/bin/sail pnpm build`
- Run frontend watch/build inside Sail: `corepack pnpm dev:platform`, `cd apps/platform && ./vendor/bin/sail pnpm dev`, or `cd apps/platform && ./vendor/bin/sail pnpm build`
- Run tests: `cd apps/platform && ./vendor/bin/sail artisan test --compact`
### Website
@ -55,6 +58,11 @@ ### Canonical Lane Commands
- `./scripts/platform-test-report browser`
- `./scripts/platform-test-report profiling`
- `./scripts/platform-test-report junit`
- Trend-aware report refresh options:
- `--history-file=/absolute/path/to/<lane>-latest.trend-history.json` seeds one prior comparable window explicitly.
- `--history-bundle=/absolute/path/to/bundle-or-zip` hydrates the newest matching `trend-history.json` from a staged artifact bundle.
- `--fetch-latest-history` asks the wrapper to download the most recent comparable bundle from Gitea when `TENANTATLAS_GITEA_TOKEN` or `GITEA_TOKEN` is available.
- `--skip-latest-history` keeps the run intentionally cold-start so the summary reports `unstable` instead of guessing at trend state.
- App-local equivalents remain available through Sail Composer scripts:
- `cd apps/platform && ./vendor/bin/sail composer run test`
- `cd apps/platform && ./vendor/bin/sail composer run test:confidence`
@ -64,11 +72,35 @@ ### Canonical Lane Commands
- `cd apps/platform && ./vendor/bin/sail composer run test:junit`
- The root wrapper is the safer default for long lanes because it pins Composer to `--timeout=0`.
### Trend Summary Reading
- `healthy`: enough comparable samples exist, the lane is comfortably under budget, and recent variance stays inside the documented noise floor.
- `budget-near`: the lane is still within budget, but headroom has entered the lane's near-budget band and needs attention before it becomes a repeated blocker.
- `trending-worse`: multiple comparable samples are worsening above the lane variance floor even though the lane is not yet clearly over budget.
- `regressed`: the lane is over budget or repeatedly worsening enough that ordinary noise is no longer a credible explanation.
- `unstable`: the report intentionally refuses a stronger label because history is too short, the comparison fingerprint changed, or the recent window is noisy.
- Recalibration is separate from health. Reports can emit candidate, approved, or rejected baseline or budget decisions, but repository truth never moves automatically.
- Hotspot evidence may be unavailable on a given cycle. When that happens the summary must say so explicitly, and `profiling` or `junit` remain the preferred support-lane follow-up paths.
### Workflow Expectation
- Every runtime-changing spec, plan, and task set MUST record the target validation lane(s), fixture-cost risks, any heavy-governance or browser expansion, and any budget/baseline follow-up.
- Every runtime-changing or test-affecting spec, plan, and task set MUST record actual test-purpose classification, target validation lane(s), fixture-cost risks, any heavy-governance or browser expansion, any heavy-family visibility change, and any budget/baseline/trend follow-up.
- Test classification follows the real proving purpose of the change, not the filename or folder.
- Minimal fixtures and minimal infrastructure are the default; database, Livewire, Filament, provider, workspace, membership, or session-heavy setup must stay explicit and opt-in.
- Review treats wrong lane fit, hidden default cost, accidental heavy-family growth, or undocumented runtime drift as merge issues, not later cleanup.
- Routine lane recalibration belongs inside the affecting feature spec or PR; open a dedicated follow-up spec only when recurring pain or structural lane changes justify it.
### Authoring And Review Guardrails
- Start with the smallest honest surface: `Unit` for isolated logic, `Feature` for HTTP, Livewire, Filament, jobs, or non-browser integration, `heavy-governance` for intentionally expensive governance scans, and `Browser` only for end-to-end workflow coverage.
- Specs and plans must state the affected lanes or a deliberate `N/A`, the family impact, the setup-cost impact, and the narrowest reviewer command.
- If database, Livewire, Filament, provider setup, workspace or membership context, session state, capability context, or browser coverage is required, say why a narrower proof is insufficient.
- Keep shared helpers, factories, seeds, fixtures, and defaults cheap by default. Full-context setup should stay behind explicit opt-ins instead of becoming the default path.
- Extend an existing heavy or browser family only when the behavior truly matches it. New heavy families, new browser scope, revived expensive defaults, or material lane-cost shifts require explicit escalation.
- Low-impact docs-only or template-only work may answer the governance prompts with `N/A` or `none`; do not invent runtime impact where none exists.
- Review should end with one explicit outcome: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
- Use `document-in-feature` for contained drift or cost that belongs in the active feature. Use `follow-up-spec` for recurring pain or structural lane-model changes. Use `reject-or-split` when hidden cost is still unresolved.
### CI Trigger Matrix
- Pull requests (`opened`, `reopened`, `synchronize`) run only `./scripts/platform-test-lane fast-feedback` through `.gitea/workflows/test-pr-fast-feedback.yml` and block on test, wrapper, artifact, and mature Fast Feedback budget failures.
@ -81,7 +113,8 @@ ### CI Artifact Bundles
- Lane-local artifacts are still generated in `apps/platform/storage/logs/test-lanes` as `*-latest.*` files.
- CI workflows stage deterministic upload bundles through `./scripts/platform-test-artifacts` into `.gitea-artifacts/<workflow-profile>` before upload.
- Every governed CI lane publishes `summary.md`, `budget.json`, `report.json`, and `junit.xml`. `profiling` may additionally publish `profile.txt`.
- Every governed CI lane now publishes `summary.md`, `budget.json`, `report.json`, `junit.xml`, and `trend-history.json`. `profiling` may additionally publish `profile.txt`.
- The report refresh step hydrates the most recent comparable `trend-history.json` before regenerating the current summary when CI credentials allow it, then republishes the refreshed bounded history for the next run.
- Artifact publication failures are first-class blocking failures for pull request and `dev` workflows.
### Recorded Baselines

View File

@ -127,12 +127,14 @@ public function selectWorkspace(int $workspaceId): void
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$redirectTarget = $resolver->resolve(
$workspace,
$user,
WorkspaceIntendedUrl::consume(request()),
);
$this->redirect($redirectTarget);
}
@ -170,12 +172,14 @@ public function createWorkspace(array $data): void
->success()
->send();
$intendedUrl = WorkspaceIntendedUrl::consume(request());
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
$redirectTarget = $intendedUrl ?: $resolver->resolve($workspace, $user);
$redirectTarget = $resolver->resolve(
$workspace,
$user,
WorkspaceIntendedUrl::consume(request()),
);
$this->redirect($redirectTarget);
}

View File

@ -69,15 +69,13 @@ public function __invoke(Request $request): RedirectResponse
resourceId: (string) $workspace->getKey(),
);
$intendedUrl = WorkspaceIntendedUrl::consume($request);
if ($intendedUrl !== null) {
return redirect()->to($intendedUrl);
}
/** @var WorkspaceRedirectResolver $resolver */
$resolver = app(WorkspaceRedirectResolver::class);
return redirect()->to($resolver->resolve($workspace, $user));
return redirect()->to($resolver->resolve(
$workspace,
$user,
WorkspaceIntendedUrl::consume($request),
));
}
}

View File

@ -8,18 +8,14 @@
use App\Filament\Resources\AlertRuleResource;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceRoleCapabilityMap;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Auth\Capabilities;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Closure;
use Filament\Facades\Filament;
use Filament\Models\Contracts\HasTenants;
use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationItem;
use Illuminate\Http\Request;
@ -33,6 +29,7 @@ class EnsureFilamentTenantSelected
public function handle(Request $request, Closure $next): Response
{
$panel = Filament::getCurrentOrDefaultPanel();
$resolvedContext = app(OperateHubShell::class)->resolvedContext($request);
$path = '/'.ltrim($request->path(), '/');
@ -85,75 +82,27 @@ public function handle(Request $request, Closure $next): Response
return $next($request);
}
$tenantParameter = null;
if ($request->route()?->hasParameter('tenant')) {
$tenantParameter = $request->route()->parameter('tenant');
} elseif (filled($request->query('tenant'))) {
$tenantParameter = $request->query('tenant');
}
if (
$tenantParameter === null
&& ! $this->hasCanonicalTenantSelection($request)
! $resolvedContext->hasTenant()
&& $this->adminPathRequiresTenantSelection($path)
) {
return redirect()->route('filament.admin.pages.choose-tenant');
}
if ($tenantParameter !== null) {
$user = $request->user();
if ($resolvedContext->pageCategory === TenantPageCategory::TenantBound && ! $resolvedContext->hasTenant()) {
abort(404);
}
if ($user === null) {
return $next($request);
}
if (! $user instanceof HasTenants) {
abort(404);
}
$tenant = $tenantParameter instanceof Tenant
? $tenantParameter
: Tenant::query()->withTrashed()->where('external_id', (string) $tenantParameter)->first();
if (! $tenant instanceof Tenant) {
abort(404);
}
if ($workspaceId === null) {
abort(404);
}
if ((int) $tenant->workspace_id !== (int) $workspaceId) {
abort(404);
}
$workspace = Workspace::query()->whereKey($workspaceId)->first();
if (! $workspace instanceof Workspace) {
abort(404);
}
if (! $user instanceof User || ! $workspaceContext->isMember($user, $workspace)) {
abort(404);
}
if (! $this->routeTenantIsAuthorized($tenant, $user, $workspaceId, $path)) {
abort(404);
}
if ($this->isWorkspaceScopedPageWithTenant($path)) {
$this->configureNavigationForRequest($panel);
return $next($request);
}
Filament::setTenant($tenant, true);
app(WorkspaceContext::class)->rememberTenantContext($tenant, $request);
$this->configureNavigationForRequest($panel);
return $next($request);
if (
$resolvedContext->hasTenant()
&& (
$panel?->getId() === 'tenant'
|| (! $this->isWorkspaceScopedPageWithTenant($path) && $resolvedContext->pageCategory === TenantPageCategory::TenantBound)
)
) {
Filament::setTenant($resolvedContext->tenant, true);
} elseif (! $resolvedContext->hasTenant()) {
Filament::setTenant(null, true);
}
if (
@ -291,27 +240,4 @@ private function adminPathRequiresTenantSelection(string $path): bool
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
}
private function hasCanonicalTenantSelection(Request $request): bool
{
return app(OperateHubShell::class)->activeEntitledTenant($request) instanceof Tenant;
}
private function routeTenantIsAuthorized(Tenant $tenant, User $user, int $workspaceId, string $path): bool
{
$pageCategory = TenantPageCategory::fromPath($path);
$question = match ($pageCategory) {
TenantPageCategory::TenantBound => TenantOperabilityQuestion::TenantBoundViewability,
default => TenantOperabilityQuestion::AdministrativeDiscoverability,
};
return app(TenantOperabilityService::class)->outcomeFor(
tenant: $tenant,
question: $question,
actor: $user,
workspaceId: $workspaceId,
lane: $pageCategory->lane(),
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
)->allowed;
}
}

View File

@ -7,9 +7,9 @@
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Tenants\TenantOperabilityService;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantOperabilityQuestion;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
@ -19,6 +19,8 @@
final class OperateHubShell
{
private const string REQUEST_ATTRIBUTE = 'tenantpilot.resolved_shell_context';
public function __construct(
private WorkspaceContext $workspaceContext,
private CapabilityResolver $capabilityResolver,
@ -83,7 +85,7 @@ public function headerActions(
public function activeEntitledTenant(?Request $request = null): ?Tenant
{
return $this->resolveActiveTenant($request);
return $this->resolvedContext($request)->tenant;
}
public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
@ -91,42 +93,162 @@ public function tenantOwnedPanelContext(?Request $request = null): ?Tenant
return $this->activeEntitledTenant($request);
}
private function resolveActiveTenant(?Request $request = null): ?Tenant
public function resolvedContext(?Request $request = null): ResolvedShellContext
{
$pageCategory = $this->pageCategory($request);
$routeTenant = $this->resolveRouteTenant($request, $pageCategory);
$request ??= request();
if ($routeTenant instanceof Tenant) {
return $routeTenant;
if ($request instanceof Request) {
$cached = $request->attributes->get(self::REQUEST_ATTRIBUTE);
if ($cached instanceof ResolvedShellContext) {
return $cached;
}
}
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory);
$resolved = $this->buildResolvedContext($request);
if ($tenant instanceof Tenant) {
return $tenant;
if ($request instanceof Request) {
$request->attributes->set(self::REQUEST_ATTRIBUTE, $resolved);
}
if ($pageCategory === TenantPageCategory::TenantBound) {
return null;
}
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
if (! $rememberedTenant instanceof Tenant) {
return null;
}
if (! $this->isRememberedTenantValid($rememberedTenant, $request)) {
$this->workspaceContext->clearRememberedTenantContext($request);
return null;
}
return $rememberedTenant;
return $resolved;
}
private function resolveValidatedFilamentTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
private function buildResolvedContext(?Request $request = null): ResolvedShellContext
{
$pageCategory = $this->pageCategory($request);
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
$workspaceSource = match (true) {
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
$workspace instanceof Workspace && $routeTenantCandidate instanceof Tenant && (int) $routeTenantCandidate->workspace_id === (int) $workspace->getKey() => 'route',
default => 'none',
};
if (! $workspace instanceof Workspace) {
return new ResolvedShellContext(
workspace: null,
tenant: null,
pageCategory: $pageCategory,
state: 'missing_workspace',
displayMode: 'recovery',
recoveryAction: $pageCategory === TenantPageCategory::WorkspaceChooserException ? 'none' : 'redirect_choose_workspace',
recoveryDestination: '/admin/choose-workspace',
recoveryReason: 'missing_workspace',
);
}
$routeTenant = $this->resolveValidatedRouteTenant($routeTenantCandidate, $workspace, $request, $pageCategory);
if ($routeTenant['tenant'] instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $routeTenant['tenant'],
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'route',
);
}
$recoveryReason = $routeTenant['reason'];
if ($pageCategory === TenantPageCategory::TenantBound && $recoveryReason !== null) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'invalid_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
recoveryAction: 'abort_not_found',
recoveryReason: $recoveryReason,
);
}
$queryHintTenant = $this->resolveValidatedQueryHintTenant($request, $workspace, $pageCategory);
if ($queryHintTenant['tenant'] instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $queryHintTenant['tenant'],
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'query_hint',
);
}
$recoveryReason ??= $queryHintTenant['reason'];
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
if ($tenant instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $tenant,
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'filament_tenant',
);
}
if ($pageCategory->allowsRememberedTenantRestore()) {
$rememberedTenant = $this->workspaceContext->rememberedTenant($request);
if ($rememberedTenant instanceof Tenant) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: $rememberedTenant,
pageCategory: $pageCategory,
state: 'tenant_scoped',
displayMode: 'tenant_scoped',
workspaceSource: $workspaceSource,
tenantSource: 'remembered',
);
}
}
if ($pageCategory->requiresExplicitTenant()) {
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'missing_tenant',
displayMode: 'recovery',
workspaceSource: $workspaceSource,
recoveryAction: $pageCategory === TenantPageCategory::TenantScopedEvidence
? 'redirect_evidence_overview'
: 'abort_not_found',
recoveryDestination: $pageCategory === TenantPageCategory::TenantScopedEvidence
? '/admin/evidence/overview'
: null,
recoveryReason: $recoveryReason ?? 'missing_tenant',
);
}
return new ResolvedShellContext(
workspace: $workspace,
tenant: null,
pageCategory: $pageCategory,
state: 'tenantless_workspace',
displayMode: 'tenantless',
workspaceSource: $workspaceSource,
recoveryReason: $recoveryReason,
);
}
private function resolveValidatedFilamentTenant(
?Request $request = null,
?TenantPageCategory $pageCategory = null,
?Workspace $workspace = null,
): ?Tenant {
$tenant = Filament::getTenant();
if (! $tenant instanceof Tenant) {
@ -134,8 +256,9 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
}
$pageCategory ??= $this->pageCategory($request);
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
if ($this->isContextTenantEntitled($tenant, $request, $pageCategory)) {
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
return $tenant;
}
@ -144,19 +267,58 @@ private function resolveValidatedFilamentTenant(?Request $request = null, ?Tenan
return null;
}
private function resolveRouteTenant(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
private function resolveValidatedRouteTenant(
?Tenant $tenant,
Workspace $workspace,
?Request $request = null,
?TenantPageCategory $pageCategory = null,
): array {
$pageCategory ??= $this->pageCategory($request);
if (! $tenant instanceof Tenant) {
return ['tenant' => null, 'reason' => null];
}
$reason = $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory);
if ($reason !== null) {
return ['tenant' => null, 'reason' => $reason];
}
return ['tenant' => $tenant, 'reason' => null];
}
private function resolveValidatedQueryHintTenant(
?Request $request,
Workspace $workspace,
TenantPageCategory $pageCategory,
): array {
if (! $pageCategory->allowsQueryTenantHints()) {
return ['tenant' => null, 'reason' => null];
}
$queryTenant = $this->resolveQueryTenantHint($request);
if (! $queryTenant instanceof Tenant) {
return ['tenant' => null, 'reason' => null];
}
$reason = $this->tenantValidationReason($queryTenant, $workspace, $request, $pageCategory);
if ($reason !== null) {
return ['tenant' => null, 'reason' => $reason];
}
return ['tenant' => $queryTenant, 'reason' => null];
}
private function resolveRouteTenantCandidate(?Request $request = null, ?TenantPageCategory $pageCategory = null): ?Tenant
{
$route = $request?->route();
$pageCategory ??= $this->pageCategory($request);
if ($route?->hasParameter('tenant')) {
$tenant = $this->resolveTenantRouteParameter($route->parameter('tenant'));
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
return null;
}
return $tenant;
return $this->resolveTenantIdentifier($route->parameter('tenant'));
}
if (
@ -167,24 +329,35 @@ private function resolveRouteTenant(?Request $request = null, ?TenantPageCategor
return null;
}
$tenant = $this->resolveTenantRouteParameter($route->parameter('record'));
if (! $tenant instanceof Tenant || ! $this->isRouteTenantEntitled($tenant, $request, $pageCategory)) {
return null;
}
return $tenant;
return $this->resolveTenantIdentifier($route->parameter('record'));
}
private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
private function resolveQueryTenantHint(?Request $request = null): ?Tenant
{
if ($routeTenant instanceof Tenant) {
return $routeTenant;
$queryTenant = $request?->query('tenant');
if (filled($queryTenant)) {
return $this->resolveTenantIdentifier($queryTenant);
}
$routeTenant = trim((string) $routeTenant);
$queryTenantId = $request?->query('tenant_id');
if ($routeTenant === '') {
if (filled($queryTenantId)) {
return $this->resolveTenantIdentifier($queryTenantId);
}
return null;
}
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
{
if ($tenantIdentifier instanceof Tenant) {
return $tenantIdentifier;
}
$tenantIdentifier = trim((string) $tenantIdentifier);
if ($tenantIdentifier === '') {
return null;
}
@ -192,94 +365,58 @@ private function resolveTenantRouteParameter(mixed $routeTenant): ?Tenant
return Tenant::query()
->withTrashed()
->where(static function ($query) use ($routeTenant, $tenantKeyColumn): void {
$query->where('external_id', $routeTenant);
->where(static function ($query) use ($tenantIdentifier, $tenantKeyColumn): void {
$query->where('external_id', $tenantIdentifier);
if (ctype_digit($routeTenant)) {
$query->orWhere($tenantKeyColumn, (int) $routeTenant);
if (ctype_digit($tenantIdentifier)) {
$query->orWhere($tenantKeyColumn, (int) $tenantIdentifier);
}
})
->first();
}
private function isRouteTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
{
$pageCategory ??= TenantPageCategory::fromRequest($request);
private function tenantValidationReason(
Tenant $tenant,
Workspace $workspace,
?Request $request = null,
?TenantPageCategory $pageCategory = null,
): ?string {
$pageCategory ??= $this->pageCategory($request);
if ($pageCategory !== TenantPageCategory::TenantBound) {
return $this->isContextTenantEntitled($tenant, $request, $pageCategory);
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
return 'mismatched_workspace';
}
return $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::TenantBoundViewability,
lane: TenantInteractionLane::AdministrativeManagement,
);
}
private function isContextTenantEntitled(Tenant $tenant, ?Request $request = null, ?TenantPageCategory $pageCategory = null): bool
{
$pageCategory ??= TenantPageCategory::fromRequest($request);
return match ($pageCategory) {
TenantPageCategory::TenantBound => $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::TenantBoundViewability,
lane: TenantInteractionLane::AdministrativeManagement,
),
TenantPageCategory::CanonicalWorkspaceRecordViewer,
TenantPageCategory::OnboardingWorkflow,
TenantPageCategory::WorkspaceScoped => $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::AdministrativeDiscoverability,
lane: TenantInteractionLane::AdministrativeManagement,
),
};
}
private function isRememberedTenantValid(Tenant $tenant, ?Request $request = null): bool
{
return $this->evaluateOutcome(
tenant: $tenant,
request: $request,
question: TenantOperabilityQuestion::RememberedContextValidity,
lane: TenantInteractionLane::StandardActiveOperating,
);
}
private function evaluateOutcome(
Tenant $tenant,
?Request $request,
TenantOperabilityQuestion $question,
TenantInteractionLane $lane,
): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
$workspaceId = $this->workspaceContext->currentWorkspaceId($request);
if ($workspaceId !== null && (int) $tenant->workspace_id !== (int) $workspaceId) {
return false;
return 'not_member';
}
if (! $this->capabilityResolver->isMember($user, $tenant)) {
return false;
return 'not_member';
}
return $this->tenantOperabilityService->outcomeFor(
$question = $pageCategory === TenantPageCategory::TenantBound
? TenantOperabilityQuestion::TenantBoundViewability
: TenantOperabilityQuestion::AdministrativeDiscoverability;
$allowed = $this->tenantOperabilityService->outcomeFor(
tenant: $tenant,
question: $question,
actor: $user,
workspaceId: $workspaceId,
lane: $lane,
workspaceId: (int) $workspace->getKey(),
lane: $pageCategory->lane(),
selectedTenant: Filament::getTenant() instanceof Tenant ? Filament::getTenant() : null,
)->allowed;
if ($allowed) {
return null;
}
return $pageCategory === TenantPageCategory::TenantBound
? 'inaccessible'
: 'not_operable';
}
private function pageCategory(?Request $request = null): TenantPageCategory

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Support\OperateHub;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Support\Tenants\TenantPageCategory;
final readonly class ResolvedShellContext
{
public function __construct(
public ?Workspace $workspace,
public ?Tenant $tenant,
public TenantPageCategory $pageCategory,
public string $state,
public string $displayMode,
public string $workspaceSource = 'none',
public string $tenantSource = 'none',
public string $recoveryAction = 'none',
public ?string $recoveryDestination = null,
public ?string $recoveryReason = null,
) {}
public function hasWorkspace(): bool
{
return $this->workspace instanceof Workspace;
}
public function hasTenant(): bool
{
return $this->tenant instanceof Tenant;
}
public function showsRecoveryNotice(): bool
{
return $this->displayMode === 'recovery' || $this->recoveryReason !== null;
}
}

View File

@ -15,9 +15,11 @@ public static function fromPageCategory(TenantPageCategory $pageCategory): self
{
return match ($pageCategory) {
TenantPageCategory::OnboardingWorkflow => self::OnboardingWorkflow,
TenantPageCategory::TenantBound => self::AdministrativeManagement,
TenantPageCategory::TenantBound,
TenantPageCategory::TenantScopedEvidence => self::AdministrativeManagement,
TenantPageCategory::CanonicalWorkspaceRecordViewer => self::CanonicalWorkspaceRecord,
TenantPageCategory::WorkspaceScoped => self::StandardActiveOperating,
TenantPageCategory::WorkspaceScoped,
TenantPageCategory::WorkspaceChooserException => self::StandardActiveOperating,
};
}
}

View File

@ -9,7 +9,9 @@
enum TenantPageCategory: string
{
case WorkspaceScoped = 'workspace_scoped';
case WorkspaceChooserException = 'workspace_chooser_exception';
case TenantBound = 'tenant_bound';
case TenantScopedEvidence = 'tenant_scoped_evidence';
case OnboardingWorkflow = 'onboarding_workflow';
case CanonicalWorkspaceRecordViewer = 'canonical_workspace_record_viewer';
@ -26,10 +28,21 @@ public static function fromPath(string $path): self
{
$normalizedPath = '/'.ltrim($path, '/');
if ($normalizedPath === '/admin/choose-workspace') {
return self::WorkspaceChooserException;
}
if (preg_match('#^/admin/operations/[^/]+$#', $normalizedPath) === 1) {
return self::CanonicalWorkspaceRecordViewer;
}
if (
str_starts_with($normalizedPath, '/admin/evidence/')
&& ! str_starts_with($normalizedPath, '/admin/evidence/overview')
) {
return self::TenantScopedEvidence;
}
if (preg_match('#^/admin/onboarding(?:/[^/]+)?$#', $normalizedPath) === 1) {
return self::OnboardingWorkflow;
}
@ -44,6 +57,41 @@ public static function fromPath(string $path): self
return self::WorkspaceScoped;
}
public function allowsQueryTenantHints(): bool
{
return match ($this) {
self::WorkspaceScoped, self::OnboardingWorkflow => true,
default => false,
};
}
public function allowsRememberedTenantRestore(): bool
{
return match ($this) {
self::WorkspaceScoped, self::OnboardingWorkflow, self::CanonicalWorkspaceRecordViewer => true,
default => false,
};
}
public function allowsTenantlessState(): bool
{
return match ($this) {
self::WorkspaceScoped,
self::WorkspaceChooserException,
self::OnboardingWorkflow,
self::CanonicalWorkspaceRecordViewer => true,
default => false,
};
}
public function requiresExplicitTenant(): bool
{
return match ($this) {
self::TenantBound, self::TenantScopedEvidence => true,
default => false,
};
}
public function lane(): TenantInteractionLane
{
return TenantInteractionLane::fromPageCategory($this);

View File

@ -55,6 +55,33 @@ public function currentWorkspace(?Request $request = null): ?Workspace
return $workspace;
}
public function currentWorkspaceOrTenantWorkspace(?Tenant $tenant = null, ?Request $request = null): ?Workspace
{
$workspace = $this->currentWorkspace($request);
if ($workspace instanceof Workspace) {
return $workspace;
}
if (! $tenant instanceof Tenant) {
return null;
}
$workspace = $tenant->workspace()->first();
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace)) {
return null;
}
$user = $request?->user() instanceof User ? $request->user() : auth()->user();
if (! $user instanceof User || ! $this->isMember($user, $workspace) || ! $this->userCanAccessTenant($tenant, $request)) {
return null;
}
return $workspace;
}
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
{
$session = ($request && $request->hasSession()) ? $request->session() : session();

View File

@ -7,6 +7,7 @@
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Tenants\TenantOperabilityService;
@ -30,8 +31,12 @@ public function __construct(
*
* Returns a fully qualified URL string.
*/
public function resolve(Workspace $workspace, User $user): string
public function resolve(Workspace $workspace, User $user, ?string $intendedUrl = null): string
{
if (is_string($intendedUrl) && $this->intendedUrlMatchesWorkspace($intendedUrl, $workspace, $user)) {
return $intendedUrl;
}
$selectableTenants = $this->tenantOperabilityService->filterSelectable($user->tenants()
->where('workspace_id', $workspace->getKey())
->orderBy('name')
@ -71,4 +76,45 @@ public function resolveFromId(int $workspaceId, User $user): string
return $this->resolve($workspace, $user);
}
private function intendedUrlMatchesWorkspace(string $intendedUrl, Workspace $workspace, User $user): bool
{
$path = '/'.ltrim((string) (parse_url($intendedUrl, PHP_URL_PATH) ?? ''), '/');
if (! str_starts_with($path, '/admin')) {
return false;
}
if (preg_match('#^/admin/(?:t|tenants)/([^/]+)(?:/|$)#', $path, $matches) === 1) {
return $this->tenantIdentifierMatchesWorkspace($matches[1], $workspace, $user);
}
parse_str((string) (parse_url($intendedUrl, PHP_URL_QUERY) ?? ''), $query);
$tenantIdentifier = $query['tenant'] ?? $query['tenant_id'] ?? null;
if ($tenantIdentifier !== null && ! $this->tenantIdentifierMatchesWorkspace((string) $tenantIdentifier, $workspace, $user)) {
return false;
}
return true;
}
private function tenantIdentifierMatchesWorkspace(string $identifier, Workspace $workspace, User $user): bool
{
$tenant = Tenant::query()
->withTrashed()
->where(static function ($query) use ($identifier): void {
$query->where('external_id', $identifier);
if (ctype_digit($identifier)) {
$query->orWhereKey((int) $identifier);
}
})
->first();
return $tenant instanceof Tenant
&& (int) $tenant->workspace_id === (int) $workspace->getKey()
&& $user->canAccessTenant($tenant);
}
}

View File

@ -95,6 +95,9 @@
"test:report:profile": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('profiling', ''));\""
],
"test:report:junit": [
"@php -r \"require 'vendor/autoload.php'; exit(\\Tests\\Support\\TestLaneManifest::renderLatestReport('junit', ''));\""
],
"test:pgsql": [
"Composer\\Config::disableProcessTimeout",
"@php vendor/bin/pest -c phpunit.pgsql.xml"

View File

@ -4,14 +4,14 @@
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Tenants\TenantPageCategory;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
/** @var WorkspaceContext $workspaceContext */
$workspaceContext = app(WorkspaceContext::class);
$resolvedContext = app(OperateHubShell::class)->resolvedContext(request());
$workspace = $workspaceContext->currentWorkspace(request());
$workspace = $resolvedContext->workspace;
$user = auth()->user();
@ -22,29 +22,17 @@
->values();
}
$operateHubShell = app(OperateHubShell::class);
$currentTenant = $operateHubShell->activeEntitledTenant(request());
$currentTenant = $resolvedContext->tenant;
$currentTenantId = $currentTenant instanceof Tenant ? (int) $currentTenant->getKey() : null;
$currentTenantName = $currentTenant instanceof Tenant ? $currentTenant->getFilamentName() : null;
$hasAnyFilamentTenantContext = Filament::getTenant() instanceof Tenant;
$route = request()->route();
$routeName = (string) ($route?->getName() ?? '');
$pageCategory = TenantPageCategory::fromRequest(request());
$tenantQuery = request()->query('tenant');
$hasTenantQuery = is_string($tenantQuery) && trim($tenantQuery) !== '';
$isTenantScopedRoute = $pageCategory === TenantPageCategory::TenantBound
|| ($hasTenantQuery && str_starts_with($routeName, 'filament.admin.'));
$lastTenantId = $workspaceContext->lastTenantId(request());
$canClearTenantContext = $hasAnyFilamentTenantContext || $lastTenantId !== null;
$canClearTenantContext = $currentTenant instanceof Tenant || $lastTenantId !== null;
@endphp
@php
$tenantLabel = $currentTenantName ?? 'All tenants';
$workspaceLabel = $workspace?->name ?? 'Select workspace';
$tenantLabel = $currentTenantName ?? 'No tenant selected';
$workspaceLabel = $workspace?->name ?? 'Choose workspace';
$hasActiveTenant = $currentTenantName !== null;
$managedTenantsUrl = $workspace
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
@ -52,7 +40,7 @@
$workspaceUrl = $workspace
? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? ($hasActiveTenant ? $tenantLabel : 'No tenant selected') : 'Select tenant';
$tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace';
@endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
@ -88,6 +76,18 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
<x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
@if ($resolvedContext->showsRecoveryNotice())
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
<div class="font-semibold">Context unavailable</div>
@if ($workspace)
<div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div>
@else
<div>Choose a workspace to continue with a valid admin context.</div>
@endif
</div>
@endif
{{-- Workspace section --}}
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
@ -128,16 +128,28 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
</div>
</div>
@if ($isTenantScopedRoute)
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
<a
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
Switch tenant
</a>
@if ($resolvedContext->pageCategory->requiresExplicitTenant() && $hasActiveTenant)
<div class="space-y-2">
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName }}</span>
<a
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
>
Switch tenant
</a>
</div>
@if ($canClearTenantContext)
<form method="POST" action="{{ route('admin.clear-tenant-context') }}">
@csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
Clear tenant scope
</button>
</form>
@endif
</div>
@else
@if ($tenants->isEmpty())

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows a recovery label when workspace-scoped surfaces render without an active workspace', function (): void {
$tenant = Tenant::factory()->active()->create(['name' => 'Recovery Tenant']);
[$user] = createUserWithTenant(tenant: $tenant, role: 'owner');
session()->forget(WorkspaceContext::SESSION_KEY);
$this->actingAs($user)
->get('/admin/workspaces')
->assertOk()
->assertSee('Context unavailable')
->assertSee('Choose workspace');
});
it('shows explicit recovery wording when an invalid tenant hint is discarded on a workspace route', function (): void {
$validTenant = Tenant::factory()->active()->create(['name' => 'Valid Workspace Tenant']);
[$user, $validTenant] = createUserWithTenant(tenant: $validTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $validTenant->workspace_id,
])
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
->assertOk()
->assertSee('Context unavailable')
->assertSee('No tenant selected')
->assertDontSee('Tenant scope: '.$foreignTenant->name);
});

View File

@ -5,6 +5,7 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@ -63,3 +64,36 @@
->assertSee($workspace->name)
->assertDontSee('name="workspace_id"', escape: false);
});
test('workspace-scoped operations honor a valid tenant query hint over remembered tenant context', function () {
$rememberedTenant = Tenant::factory()->create([
'workspace_id' => null,
'status' => 'active',
'name' => 'Remembered Topbar Tenant',
]);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
$hintedTenant = Tenant::factory()->create([
'workspace_id' => (int) $rememberedTenant->workspace_id,
'status' => 'active',
'name' => 'Hinted Topbar Tenant',
]);
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
Filament::setTenant(null, true);
$workspaceId = (int) $rememberedTenant->workspace_id;
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => $workspaceId,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
],
])
->get(route('admin.operations.index', ['tenant' => $hintedTenant->external_id]))
->assertOk()
->assertSee('Tenant scope: Hinted Topbar Tenant')
->assertDontSee('Tenant scope: Remembered Topbar Tenant');
});

View File

@ -13,8 +13,12 @@
->and($workflowProfile['branchFilters'])->toBe(['dev'])
->and($workflowContents)->toContain('push:')
->and($workflowContents)->toContain('- dev')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('./scripts/platform-test-lane confidence --workflow-id=main-confidence --trigger-class=mainline-push')
->and($workflowContents)->toContain('./scripts/platform-test-report confidence --workflow-id=main-confidence --trigger-class=mainline-push')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report confidence --workflow-id=main-confidence --trigger-class=mainline-push --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts confidence .gitea-artifacts/main-confidence --workflow-id=main-confidence --trigger-class=mainline-push')
->and($workflowContents)->toContain('name: confidence-artifacts')
->and($workflowContents)->not->toContain('test:junit', './scripts/platform-test-lane fast-feedback', './scripts/platform-test-lane heavy-governance');

View File

@ -12,9 +12,13 @@
->and($workflowProfile['triggerClass'])->toBe('pull-request')
->and($workflowProfile['laneBindings'])->toBe(['fast-feedback'])
->and($workflowContents)->toContain('pull_request:')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('opened', 'reopened', 'synchronize')
->and($workflowContents)->toContain('./scripts/platform-test-lane fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request')
->and($workflowContents)->toContain('./scripts/platform-test-report fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts fast-feedback .gitea-artifacts/pr-fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request')
->and($workflowContents)->toContain('name: fast-feedback-artifacts')
->and($workflowContents)->not->toContain('confidence --workflow-id=pr-fast-feedback', 'heavy-governance', 'browser --workflow-id=pr-fast-feedback');

View File

@ -15,11 +15,16 @@
->and($scheduledProfile['scheduleCron'])->toBe('17 4 * * 1-5')
->and($workflowContents)->toContain('workflow_dispatch:')
->and($workflowContents)->toContain('schedule:')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('17 4 * * 1-5')
->and($workflowContents)->toContain("vars.TENANTATLAS_ENABLE_HEAVY_GOVERNANCE_SCHEDULE == '1'")
->and($workflowContents)->toContain('workflow_id=heavy-governance-manual')
->and($workflowContents)->toContain('workflow_id=heavy-governance-scheduled')
->and($workflowContents)->toContain('./scripts/platform-test-lane heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts heavy-governance .gitea-artifacts/heavy-governance --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->not->toContain('pull_request:', './scripts/platform-test-lane browser');
});
@ -35,11 +40,16 @@
->and($scheduledProfile['scheduleCron'])->toBe('43 4 * * 1-5')
->and($workflowContents)->toContain('workflow_dispatch:')
->and($workflowContents)->toContain('schedule:')
->and($workflowContents)->toContain('permissions:')
->and($workflowContents)->toContain('actions: read')
->and($workflowContents)->toContain('contents: read')
->and($workflowContents)->toContain('43 4 * * 1-5')
->and($workflowContents)->toContain("vars.TENANTATLAS_ENABLE_BROWSER_SCHEDULE == '1'")
->and($workflowContents)->toContain('workflow_id=browser-manual')
->and($workflowContents)->toContain('workflow_id=browser-scheduled')
->and($workflowContents)->toContain('./scripts/platform-test-lane browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->toContain('TENANTATLAS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}')
->and($workflowContents)->toContain('./scripts/platform-test-report browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }} --fetch-latest-history')
->and($workflowContents)->toContain('./scripts/platform-test-artifacts browser .gitea-artifacts/browser --workflow-id=${{ steps.context.outputs.workflow_id }} --trigger-class=${{ steps.context.outputs.trigger_class }}')
->and($workflowContents)->not->toContain('pull_request:', './scripts/platform-test-lane confidence');
});
});

View File

@ -25,9 +25,9 @@ function heavyGovernanceSyntheticHotspots(): array
$artifacts = TestLaneReport::artifactPaths('fast-feedback');
$artifactContract = TestLaneManifest::artifactPublicationContract('fast-feedback');
expect($artifacts)->toHaveKeys(['junit', 'summary', 'budget', 'report', 'profile']);
expect($artifacts)->toHaveKeys(['junit', 'summary', 'budget', 'report', 'profile', 'trendHistory']);
expect($artifactContract['requiredFiles'])->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml'])
expect($artifactContract['requiredFiles'])->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml', 'trend-history.json'])
->and($artifactContract['stagedNamePattern'])->toBe('{laneId}.{artifactFile}');
foreach (array_values($artifacts) as $relativePath) {
@ -73,6 +73,11 @@ function heavyGovernanceSyntheticHotspots(): array
'artifactPublicationContract',
'knownWorkflowProfiles',
'failureClasses',
'trendHistoryArtifact',
'trendCurrentAssessment',
'trendHotspotSnapshot',
'trendRecalibrationDecisions',
'trendWarnings',
'budgetContract',
'hotspotInventory',
'decompositionRecords',
@ -157,13 +162,14 @@ function heavyGovernanceSyntheticHotspots(): array
expect($stagingResult['complete'])->toBeTrue()
->and(collect($stagingResult['stagedArtifacts'])->pluck('artifactType')->all())
->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml'])
->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml', 'trend-history.json'])
->and(collect($stagingResult['stagedArtifacts'])->pluck('relativePath')->all())
->toContain(
$stagingDirectory.'/fast-feedback.summary.md',
$stagingDirectory.'/fast-feedback.budget.json',
$stagingDirectory.'/fast-feedback.report.json',
$stagingDirectory.'/fast-feedback.junit.xml',
$stagingDirectory.'/fast-feedback.trend-history.json',
);
});
@ -187,4 +193,4 @@ function heavyGovernanceSyntheticHotspots(): array
expect($fastFeedback)->toHaveKey('sharedFixtureSlimmingComparison')
->and($heavyGovernance)->not->toHaveKey('sharedFixtureSlimmingComparison');
});
});

View File

@ -21,6 +21,7 @@
'test:report:browser',
'test:report:heavy',
'test:report:profile',
'test:report:junit',
'sail:test',
])
->and(TestLaneManifest::commandRef('fast-feedback'))->toBe('test')
@ -42,7 +43,13 @@
$reportRunner = (string) file_get_contents(repo_path('scripts/platform-test-report'));
expect($laneRunner)->toContain('--capture-baseline', 'copy_heavy_baseline_artifacts', 'heavy-governance-baseline.${suffix}')
->and($reportRunner)->toContain('--capture-baseline', 'copy_heavy_baseline_artifacts', 'heavy-governance-baseline.${suffix}');
->and($reportRunner)->toContain('--capture-baseline', 'copy_heavy_baseline_artifacts', 'heavy-governance-baseline.${suffix}')
->and($reportRunner)->toContain('junit)', 'test:report:junit')
->and($reportRunner)->toContain('--history-file=')
->and($reportRunner)->toContain('--history-bundle=')
->and($reportRunner)->toContain('--fetch-latest-history')
->and($reportRunner)->toContain('TENANTATLAS_GITEA_TOKEN')
->and($reportRunner)->toContain('trend-history.json');
});
it('avoids expanding an empty forwarded-argument array in the lane runner', function (): void {
@ -120,4 +127,4 @@
->and($heavyContents)->toContain('tests/Feature/Findings/FindingBulkActionsTest.php')
->and($heavyContents)->toContain('tests/Feature/Guards/ActionSurfaceContractTest.php')
->and(TestLaneManifest::buildCommand('junit'))->toContain('--parallel');
});
});

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
use Tests\Support\TestLaneTrendFixtures;
it('hydrates an explicit prior history file into the canonical lane artifact path', function (): void {
$sourceArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/source');
$targetArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/target');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 182.4,
durationsByFile: [
'tests/Feature/Guards/TestLaneHistoryHydrationContractTest.php' => 14.2,
'tests/Feature/Guards/TestLaneArtifactsContractTest.php' => 9.8,
],
artifactDirectory: $sourceArtifactDirectory,
ciContext: [
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
$sourcePath = TestLaneManifest::absolutePath($sourceArtifactDirectory.'/seeded-fast-feedback.trend-history.json');
if (! is_dir(dirname($sourcePath))) {
mkdir(dirname($sourcePath), 0777, true);
}
file_put_contents($sourcePath, json_encode($report['trendHistoryArtifact'], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
$result = TestLaneReport::hydrateTrendHistory(
laneId: 'fast-feedback',
historyFile: $sourcePath,
artifactDirectory: $targetArtifactDirectory,
);
$hydratedArtifact = TestLaneTrendFixtures::readTrendHistory('fast-feedback', $targetArtifactDirectory);
expect($result['hydrated'])->toBeTrue()
->and($result['sourceType'])->toBe('history-file')
->and($hydratedArtifact['laneId'])->toBe('fast-feedback')
->and($hydratedArtifact['workflowProfile'])->toBe('pr-fast-feedback')
->and($hydratedArtifact['history'][0]['artifactRefs']['trendHistory'])->toBe($sourceArtifactDirectory.'/fast-feedback-latest.trend-history.json');
});
it('hydrates staged bundle directories and zip bundles using the canonical trend-history artifact name', function (): void {
$sourceArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/bundle-source');
$bundleDirectory = TestLaneManifest::absolutePath($sourceArtifactDirectory.'/bundle');
$targetArtifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-history-hydration/bundle-target');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 431.2,
durationsByFile: [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 24.7,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 20.4,
],
artifactDirectory: $sourceArtifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
);
if (! is_dir($bundleDirectory)) {
mkdir($bundleDirectory, 0777, true);
}
file_put_contents(
$bundleDirectory.'/confidence.trend-history.json',
json_encode($report['trendHistoryArtifact'], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
);
$directoryResult = TestLaneReport::hydrateTrendHistory(
laneId: 'confidence',
bundlePath: $bundleDirectory,
artifactDirectory: $targetArtifactDirectory,
);
$zipPath = $bundleDirectory.'/confidence-artifacts.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFile($bundleDirectory.'/confidence.trend-history.json', 'confidence.trend-history.json');
$zip->close();
$zipResult = TestLaneReport::hydrateTrendHistory(
laneId: 'confidence',
bundlePath: $zipPath,
artifactDirectory: $targetArtifactDirectory,
);
$hydratedArtifact = TestLaneTrendFixtures::readTrendHistory('confidence', $targetArtifactDirectory);
expect($directoryResult['hydrated'])->toBeTrue()
->and($directoryResult['sourceType'])->toBe('bundle-directory')
->and($zipResult['hydrated'])->toBeTrue()
->and($zipResult['sourceType'])->toBe('bundle-zip')
->and($hydratedArtifact['laneId'])->toBe('confidence')
->and($hydratedArtifact['history'][0]['workflowId'])->toBe('main-confidence');
});

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
use Tests\Support\TestLaneTrendFixtures;
it('surfaces top family and file hotspot deltas together with new and dropped hotspot detection', function (): void {
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-hotspots/available');
$previousDurations = [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 24.0,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 18.0,
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php' => 12.0,
];
$currentDurations = [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 41.0,
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php' => 15.0,
];
$previousReport = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 424.0,
durationsByFile: $previousDurations,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
TestLaneTrendFixtures::writeTrendHistory('confidence', $previousReport['trendHistoryArtifact'], $artifactDirectory);
$report = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 438.0,
durationsByFile: $currentDurations,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
$hotspotSnapshot = $report['trendHotspotSnapshot'];
expect($hotspotSnapshot['evidenceAvailability'])->toBe('available')
->and($hotspotSnapshot['familyDeltas'])->not->toBeEmpty()
->and(collect($hotspotSnapshot['familyDeltas'])->pluck('name')->all())
->toContain('baseline-compare-matrix-workflow', 'backup-set-admin-tenant-parity')
->and(collect($hotspotSnapshot['fileHotspots'])->pluck('name')->all())
->toContain(
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php',
'tests/Feature/Filament/BackupSetAdminTenantParityTest.php',
)
->and($hotspotSnapshot['newEntrants'])->toContain('tests/Feature/Filament/BackupSetAdminTenantParityTest.php')
->and($hotspotSnapshot['droppedEntrants'])->toContain('tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php');
});
it('discloses unavailable hotspot evidence instead of silently omitting the hotspot section', function (): void {
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-hotspots/unavailable');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 189.0,
durationsByFile: [],
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
TestLaneReport::writeArtifacts(
laneId: 'fast-feedback',
report: $report,
artifactDirectory: $artifactDirectory,
);
$summary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('fast-feedback', $artifactDirectory)['summary'],
));
expect($report['trendHotspotSnapshot']['evidenceAvailability'])->toBe('unavailable')
->and($report['trendWarnings'])->toContain('Hotspot evidence is unavailable for this cycle.')
->and($summary)->toContain('Hotspot evidence: unavailable');
});

View File

@ -12,7 +12,7 @@
static fn (array $lane): bool => $lane['defaultEntryPoint'] === true,
));
expect($manifest['version'])->toBe(2)
expect($manifest['version'])->toBe(3)
->and($manifest['artifactDirectory'])->toBe('storage/logs/test-lanes')
->and($manifest['mainlineBranch'])->toBe('dev')
->and($manifest)->toHaveKeys([
@ -27,6 +27,8 @@
'laneBindings',
'budgetEnforcementProfiles',
'artifactPublicationContracts',
'trendContractVersion',
'laneTrendPolicies',
'failureClasses',
'familyBudgets',
'heavyGovernanceBudgetContract',
@ -68,8 +70,9 @@
->and($workflowProfiles->get('heavy-governance-scheduled')['scheduleCron'])->toBe('17 4 * * 1-5')
->and($workflowProfiles->get('browser-scheduled')['scheduleCron'])->toBe('43 4 * * 1-5')
->and($laneBindings->get('fast-feedback')['executionWrapper'])->toBe('scripts/platform-test-lane')
->and($laneBindings->get('confidence')['requiredArtifacts'])->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml'])
->and($laneBindings->get('confidence')['requiredArtifacts'])->toEqualCanonicalizing(['summary.md', 'budget.json', 'report.json', 'junit.xml', 'trend-history.json'])
->and($artifactContracts->get('fast-feedback')['retentionClass'])->toBe('pr-short')
->and($artifactContracts->get('fast-feedback')['requiredFiles'])->toContain('trend-history.json')
->and($artifactContracts->get('browser')['uploadGroupName'])->toBe('browser-artifacts')
->and($failureClasses->keys()->all())->toEqualCanonicalizing([
'test-failure',
@ -192,4 +195,4 @@
'retain-intentional-heavy-depth-explicitly',
'record-helper-or-fixture-residuals',
]);
});
});

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneBudget;
use Tests\Support\TestLaneManifest;
use Tests\Support\TestLaneReport;
use Tests\Support\TestLaneTrendFixtures;
function reportWithSeededHistory(string $laneId, string $suffix, array $seededHistory, float $currentSeconds): array
{
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-recalibration/'.$suffix);
$durationsByFile = [
'tests/Feature/Guards/TestLaneRecalibrationEvidenceContractTest.php' => 11.0,
];
$workflowId = $laneId === 'confidence' ? 'main-confidence' : 'pr-fast-feedback';
$triggerClass = $laneId === 'confidence' ? 'mainline-push' : 'pull-request';
$comparisonProfile = in_array($laneId, ['fast-feedback', 'confidence'], true)
? 'shared-test-fixture-slimming'
: null;
$baseReport = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $seededHistory[0],
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
$artifact = $baseReport['trendHistoryArtifact'];
$templateRecord = $artifact['history'][0];
$artifact['history'] = array_values(array_map(
static function (float $seconds, int $index) use ($templateRecord): array {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-recalibration-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT08:30:00+00:00', $index + 1);
$record['wallClockSeconds'] = round($seconds, 6);
return $record;
},
$seededHistory,
array_keys($seededHistory),
));
TestLaneTrendFixtures::writeTrendHistory($laneId, $artifact, $artifactDirectory);
return TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $currentSeconds,
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
}
it('emits candidate, approved, and rejected recalibration records with explicit summary disclosure', function (): void {
$candidateReport = reportWithSeededHistory('confidence', 'candidate', [420.0, 380.0, 340.0, 300.0, 260.0], 460.0);
$approvedReport = reportWithSeededHistory('fast-feedback', 'approved', [176.0, 176.3, 176.1, 176.4, 176.2], 176.3);
$rejectedReport = reportWithSeededHistory('fast-feedback', 'rejected', [176.0, 191.0, 177.0, 192.0, 178.0], 193.0);
$approvedDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'baseline',
assessment: ['recalibrationRecommendation' => 'review-baseline'],
historyRecords: $approvedReport['trendHistoryArtifact']['history'],
decisionStatus: 'approved',
rationaleCode: 'post-improvement-reset',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 182.0,
notes: 'Approved baseline reset after the suite stabilized following a deliberate improvement pass.',
);
$candidateDecision = $candidateReport['trendRecalibrationDecisions'][0] ?? null;
$rejectedDecision = $rejectedReport['trendRecalibrationDecisions'][0] ?? null;
$approvedReport['trendRecalibrationDecisions'][] = $approvedDecision;
$approvedReport['trendHistoryArtifact']['recalibrationDecisions'][] = $approvedDecision;
TestLaneReport::writeArtifacts(
laneId: 'confidence',
report: $candidateReport,
artifactDirectory: $candidateReport['artifactDirectory'],
);
TestLaneReport::writeArtifacts(
laneId: 'fast-feedback',
report: $approvedReport,
artifactDirectory: $approvedReport['artifactDirectory'],
);
TestLaneReport::writeArtifacts(
laneId: 'fast-feedback',
report: $rejectedReport,
artifactDirectory: $rejectedReport['artifactDirectory'],
);
$candidateSummary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('confidence', $candidateReport['artifactDirectory'])['summary'],
));
$approvedSummary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('fast-feedback', $approvedReport['artifactDirectory'])['summary'],
));
$rejectedSummary = (string) file_get_contents(TestLaneManifest::absolutePath(
TestLaneReport::artifactPaths('fast-feedback', $rejectedReport['artifactDirectory'])['summary'],
));
expect($candidateDecision)->toBeArray()
->and($candidateDecision['decisionStatus'])->toBe('candidate')
->and($candidateDecision['targetType'])->toBe('budget')
->and($approvedDecision['decisionStatus'])->toBe('approved')
->and($approvedDecision['targetType'])->toBe('baseline')
->and($rejectedDecision)->toBeArray()
->and($rejectedDecision['decisionStatus'])->toBe('rejected')
->and($rejectedDecision['rationaleCode'])->toBe('noise-rejected')
->and($candidateSummary)->toContain('Recalibration: budget candidate')
->and($approvedSummary)->toContain('Recalibration: baseline approved')
->and($rejectedSummary)->toContain('Recalibration: budget rejected', 'noise-rejected');
});

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneBudget;
use Tests\Support\TestLaneTrendFixtures;
function recalibrationEvidenceHistory(string $laneId, int $count): array
{
$report = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $laneId === 'confidence' ? 438.0 : 188.0,
durationsByFile: [
'tests/Feature/Guards/TestLaneRecalibrationPolicyTest.php' => 12.0,
],
ciContext: [
'workflowId' => $laneId === 'confidence' ? 'main-confidence' : 'pr-fast-feedback',
'triggerClass' => $laneId === 'confidence' ? 'mainline-push' : 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: in_array($laneId, ['fast-feedback', 'confidence'], true) ? 'shared-test-fixture-slimming' : null,
);
$templateRecord = $report['trendHistoryArtifact']['history'][0];
return array_values(array_map(
static function (int $index) use ($templateRecord): array {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-evidence-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT10:00:00+00:00', $index + 1);
return $record;
},
range(0, $count - 1),
));
}
it('keeps baseline and budget recalibration rules separate and enforces the stronger budget evidence window', function (): void {
$assessment = [
'recalibrationRecommendation' => 'review-baseline',
];
$baselineHistory = recalibrationEvidenceHistory('fast-feedback', 3);
$budgetHistory = recalibrationEvidenceHistory('confidence', 5);
$baselineDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'baseline',
assessment: $assessment,
historyRecords: $baselineHistory,
decisionStatus: 'approved',
rationaleCode: 'lane-scope-change',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 184.0,
);
$budgetDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'confidence',
targetType: 'budget',
assessment: ['recalibrationRecommendation' => 'review-budget'],
historyRecords: $budgetHistory,
decisionStatus: 'approved',
rationaleCode: 'sustained-erosion',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 470.0,
);
expect($baselineDecision['targetType'])->toBe('baseline')
->and($baselineDecision['decisionStatus'])->toBe('approved')
->and($budgetDecision['targetType'])->toBe('budget')
->and($budgetDecision['decisionStatus'])->toBe('approved')
->and($budgetDecision['evidenceRunRefs'])->toHaveCount(5);
expect(static fn () => TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'confidence',
targetType: 'budget',
assessment: ['recalibrationRecommendation' => 'review-budget'],
historyRecords: recalibrationEvidenceHistory('confidence', 4),
decisionStatus: 'approved',
rationaleCode: 'sustained-erosion',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 470.0,
))->toThrow(InvalidArgumentException::class);
});
it('requires approved versus rejected rationale handling that matches the policy', function (): void {
$history = recalibrationEvidenceHistory('fast-feedback', 3);
$rejectedDecision = TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'budget',
assessment: ['recalibrationRecommendation' => 'investigate'],
historyRecords: [$history[0]],
decisionStatus: 'rejected',
rationaleCode: 'noise-rejected',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
);
expect($rejectedDecision['decisionStatus'])->toBe('rejected')
->and($rejectedDecision['rationaleCode'])->toBe('noise-rejected');
expect(static fn () => TestLaneBudget::buildRecalibrationDecisionRecord(
laneId: 'fast-feedback',
targetType: 'baseline',
assessment: ['recalibrationRecommendation' => 'review-baseline'],
historyRecords: $history,
decisionStatus: 'approved',
rationaleCode: 'sustained-erosion',
recordedIn: 'specs/211-runtime-trend-recalibration/spec.md',
proposedValueSeconds: 184.0,
))->toThrow(InvalidArgumentException::class);
});

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneTrendFixtures;
function classifiedTrendReport(string $laneId, string $suffix, array $seededHistory, float $currentSeconds): array
{
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-classification/'.$suffix);
$durationsByFile = [
'tests/Feature/Guards/TestLaneTrendClassificationTest.php' => 10.0,
'tests/Feature/Guards/TestLaneTrendSummaryContractTest.php' => 8.0,
];
$workflowId = $laneId === 'confidence' ? 'main-confidence' : 'pr-fast-feedback';
$triggerClass = $laneId === 'confidence' ? 'mainline-push' : 'pull-request';
$comparisonProfile = in_array($laneId, ['fast-feedback', 'confidence'], true)
? 'shared-test-fixture-slimming'
: null;
$baseReport = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $seededHistory[0],
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
$artifact = $baseReport['trendHistoryArtifact'];
$templateRecord = $artifact['history'][0];
$artifact['history'] = array_values(array_map(
static function (float $seconds, int $index) use ($templateRecord): array {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-history-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT09:00:00+00:00', $index + 1);
$record['wallClockSeconds'] = round($seconds, 6);
return $record;
},
$seededHistory,
array_keys($seededHistory),
));
TestLaneTrendFixtures::writeTrendHistory($laneId, $artifact, $artifactDirectory);
return TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $currentSeconds,
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $comparisonProfile,
);
}
it('classifies healthy, budget-near, trending-worse, regressed, and unstable runtime states', function (): void {
$healthy = classifiedTrendReport('fast-feedback', 'healthy', [176.1, 175.6, 176.2, 175.4, 176.0], 176.4);
$budgetNear = classifiedTrendReport('confidence', 'budget-near', [433.0, 430.0, 427.0, 424.0, 420.0], 438.5);
$trendingWorse = classifiedTrendReport('fast-feedback', 'trending-worse', [175.0, 150.0, 125.0, 100.0, 75.0], 195.0);
$regressed = classifiedTrendReport('fast-feedback', 'regressed', [200.0, 180.0, 160.0, 140.0, 120.0], 225.0);
$unstable = classifiedTrendReport('fast-feedback', 'unstable', [170.0, 195.0, 168.0, 193.0, 166.0], 194.0);
expect($healthy['trendCurrentAssessment']['healthClass'])->toBe('healthy')
->and($budgetNear['trendCurrentAssessment']['healthClass'])->toBe('budget-near')
->and($trendingWorse['trendCurrentAssessment']['healthClass'])->toBe('trending-worse')
->and($regressed['trendCurrentAssessment']['healthClass'])->toBe('regressed')
->and($unstable['trendCurrentAssessment']['healthClass'])->toBe('unstable')
->and($unstable['trendCurrentAssessment']['windowStatus'])->toBe('noisy');
});

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneTrendFixtures;
function validateTrendSchema(mixed $value, array $schema, array $defs, string $path = '$'): array
{
if (isset($schema['$ref']) && is_string($schema['$ref'])) {
$ref = $schema['$ref'];
$definitionKey = str_replace('#/$defs/', '', $ref);
return validateTrendSchema($value, $defs[$definitionKey] ?? [], $defs, $path);
}
$errors = [];
$types = $schema['type'] ?? null;
if (isset($schema['const']) && $value !== $schema['const']) {
$errors[] = sprintf('%s must equal %s.', $path, var_export($schema['const'], true));
}
if (isset($schema['enum']) && ! in_array($value, $schema['enum'], true)) {
$errors[] = sprintf('%s must be one of [%s].', $path, implode(', ', array_map(static fn (mixed $item): string => var_export($item, true), $schema['enum'])));
}
if (is_string($types)) {
$types = [$types];
}
if (is_array($types)) {
$typeValid = false;
foreach ($types as $type) {
$typeValid = match ($type) {
'array' => is_array($value) && array_is_list($value),
'object' => is_array($value) && ! array_is_list($value),
'string' => is_string($value),
'integer' => is_int($value),
'number' => is_int($value) || is_float($value),
'boolean' => is_bool($value),
'null' => $value === null,
default => false,
};
if ($typeValid) {
break;
}
}
if (! $typeValid) {
$errors[] = sprintf('%s has the wrong type.', $path);
return $errors;
}
}
if (is_array($value) && array_is_list($value)) {
if (isset($schema['minItems']) && count($value) < (int) $schema['minItems']) {
$errors[] = sprintf('%s must contain at least %d item(s).', $path, (int) $schema['minItems']);
}
if (isset($schema['items']) && is_array($schema['items'])) {
foreach ($value as $index => $item) {
$errors = array_merge($errors, validateTrendSchema($item, $schema['items'], $defs, sprintf('%s[%d]', $path, $index)));
}
}
return $errors;
}
if (is_array($value) && ! array_is_list($value)) {
foreach ($schema['required'] ?? [] as $requiredKey) {
if (! array_key_exists((string) $requiredKey, $value)) {
$errors[] = sprintf('%s is missing required key [%s].', $path, $requiredKey);
}
}
if (($schema['additionalProperties'] ?? true) === false) {
$allowedKeys = array_keys($schema['properties'] ?? []);
foreach (array_keys($value) as $key) {
if (! in_array($key, $allowedKeys, true)) {
$errors[] = sprintf('%s contains unsupported key [%s].', $path, $key);
}
}
}
foreach ($schema['properties'] ?? [] as $key => $propertySchema) {
if (! array_key_exists($key, $value)) {
continue;
}
$errors = array_merge($errors, validateTrendSchema($value[$key], $propertySchema, $defs, $path.'.'.$key));
}
return $errors;
}
if ((is_int($value) || is_float($value)) && isset($schema['minimum']) && $value < $schema['minimum']) {
$errors[] = sprintf('%s must be >= %s.', $path, $schema['minimum']);
}
return $errors;
}
it('keeps the generated trend-history artifact synchronized with the checked-in JSON schema contract', function (): void {
$schemaPath = repo_path('specs/211-runtime-trend-recalibration/contracts/test-runtime-trend-history.schema.json');
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-schema-contract');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'fast-feedback',
wallClockSeconds: 184.6,
durationsByFile: [
'tests/Feature/Guards/TestLaneTrendContractSchemaTest.php' => 16.4,
'tests/Feature/Guards/TestLaneArtifactsContractTest.php' => 11.2,
],
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: 'shared-test-fixture-slimming',
);
/** @var array<string, mixed> $schema */
$schema = json_decode((string) file_get_contents($schemaPath), true, 512, JSON_THROW_ON_ERROR);
$artifact = $report['trendHistoryArtifact'];
$errors = validateTrendSchema($artifact, $schema, $schema['$defs'] ?? []);
expect($artifact['schemaVersion'])->toBe('1.0.0')
->and($artifact['laneId'])->toBe('fast-feedback')
->and($artifact['workflowProfile'])->toBe('pr-fast-feedback')
->and($artifact['history'][0]['artifactRefs']['trendHistory'])->toBe($artifactDirectory.'/fast-feedback-latest.trend-history.json')
->and($errors)->toBe([]);
});

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
use Symfony\Component\Yaml\Yaml;
use Tests\Support\TestLaneTrendFixtures;
it('keeps the logical OpenAPI contract synchronized with the generated trend assessment vocabulary', function (): void {
$contractPath = repo_path('specs/211-runtime-trend-recalibration/contracts/test-runtime-trend.logical.openapi.yaml');
$artifactDirectory = TestLaneTrendFixtures::artifactDirectory('trend-logical-contract');
$report = TestLaneTrendFixtures::buildReport(
laneId: 'confidence',
wallClockSeconds: 438.1,
durationsByFile: [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 22.8,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 19.4,
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php' => 17.6,
],
artifactDirectory: $artifactDirectory,
ciContext: [
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
);
/** @var array<string, mixed> $contract */
$contract = Yaml::parseFile($contractPath);
$paths = $contract['paths'] ?? [];
$schemas = $contract['components']['schemas'] ?? [];
$assessment = $report['trendCurrentAssessment'];
$decision = $report['trendRecalibrationDecisions'][0] ?? null;
expect(array_keys($paths))->toEqualCanonicalizing([
'/test-governance/lanes/{laneId}/trend-history',
'/test-governance/lanes/{laneId}/trend-assessment',
'/test-governance/lanes/{laneId}/recalibration',
'/test-governance/cycles/{cycleId}/summary',
])
->and($paths['/test-governance/lanes/{laneId}/trend-history']['post']['operationId'])->toBe('updateLaneTrendHistory')
->and($paths['/test-governance/lanes/{laneId}/trend-assessment']['post']['operationId'])->toBe('evaluateLaneTrendAssessment')
->and($paths['/test-governance/lanes/{laneId}/recalibration']['post']['operationId'])->toBe('evaluateLaneRecalibration')
->and($paths['/test-governance/cycles/{cycleId}/summary']['get']['operationId'])->toBe('getTrendSummaryCycle')
->and($schemas['DriftAssessment']['properties']['healthClass']['enum'])->toEqualCanonicalizing([
'healthy',
'budget-near',
'trending-worse',
'regressed',
'unstable',
])
->and($schemas['DriftAssessment']['properties']['recalibrationRecommendation']['enum'])->toEqualCanonicalizing([
'none',
'investigate',
'review-baseline',
'review-budget',
])
->and($schemas['RecalibrationDecision']['properties']['decisionStatus']['enum'])->toEqualCanonicalizing([
'candidate',
'approved',
'rejected',
])
->and($schemas['LaneTrendHistoryArtifact']['required'])->toContain('schemaVersion', 'policy', 'history', 'currentAssessment')
->and($assessment['healthClass'])->toBeIn($schemas['DriftAssessment']['properties']['healthClass']['enum'])
->and($assessment['recalibrationRecommendation'])->toBeIn($schemas['DriftAssessment']['properties']['recalibrationRecommendation']['enum']);
if (is_array($decision)) {
expect($decision['decisionStatus'])->toBeIn($schemas['RecalibrationDecision']['properties']['decisionStatus']['enum'])
->and($decision['targetType'])->toBeIn($schemas['RecalibrationDecision']['properties']['targetType']['enum']);
}
});

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use Tests\Support\TestLaneTrendFixtures;
function seededTrendHistoryArtifact(array $baseArtifact, array $seededWallClockSeconds): array
{
$templateRecord = $baseArtifact['history'][0];
$seededHistory = [];
foreach ($seededWallClockSeconds as $index => $seconds) {
$record = $templateRecord;
$record['runRef'] = sprintf('%s-seeded-%d', $templateRecord['laneId'], $index + 1);
$record['generatedAt'] = sprintf('2026-04-%02dT12:00:00+00:00', $index + 1);
$record['wallClockSeconds'] = round((float) $seconds, 6);
$seededHistory[] = $record;
}
$baseArtifact['history'] = $seededHistory;
return $baseArtifact;
}
it('keeps the trend summary bounded while exposing current, previous, baseline, and budget fields for fast-feedback and confidence', function (): void {
$scenarios = [
'fast-feedback' => [
'artifactDirectory' => TestLaneTrendFixtures::artifactDirectory('trend-summary/fast-feedback'),
'workflowId' => 'pr-fast-feedback',
'triggerClass' => 'pull-request',
'comparisonProfile' => 'shared-test-fixture-slimming',
'durationsByFile' => [
'tests/Feature/Guards/TestLaneTrendSummaryContractTest.php' => 12.6,
'tests/Feature/Guards/TestLaneArtifactsContractTest.php' => 8.4,
],
'seededHistory' => [176.73, 178.91, 181.22, 183.41, 185.07, 186.64],
'currentSeconds' => 188.18,
],
'confidence' => [
'artifactDirectory' => TestLaneTrendFixtures::artifactDirectory('trend-summary/confidence'),
'workflowId' => 'main-confidence',
'triggerClass' => 'mainline-push',
'comparisonProfile' => 'shared-test-fixture-slimming',
'durationsByFile' => [
'tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php' => 24.3,
'tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php' => 20.4,
'tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php' => 16.1,
],
'seededHistory' => [394.38, 401.12, 407.05, 411.61, 419.24, 424.77],
'currentSeconds' => 431.81,
],
];
foreach ($scenarios as $laneId => $scenario) {
$baseReport = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $scenario['seededHistory'][0],
durationsByFile: $scenario['durationsByFile'],
artifactDirectory: $scenario['artifactDirectory'],
ciContext: [
'workflowId' => $scenario['workflowId'],
'triggerClass' => $scenario['triggerClass'],
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $scenario['comparisonProfile'],
);
TestLaneTrendFixtures::writeTrendHistory(
$laneId,
seededTrendHistoryArtifact($baseReport['trendHistoryArtifact'], $scenario['seededHistory']),
$scenario['artifactDirectory'],
);
$report = TestLaneTrendFixtures::buildReport(
laneId: $laneId,
wallClockSeconds: $scenario['currentSeconds'],
durationsByFile: $scenario['durationsByFile'],
artifactDirectory: $scenario['artifactDirectory'],
ciContext: [
'workflowId' => $scenario['workflowId'],
'triggerClass' => $scenario['triggerClass'],
'entryPointResolved' => true,
'workflowLaneMatched' => true,
],
comparisonProfile: $scenario['comparisonProfile'],
);
expect($report['trendHistoryArtifact']['history'])->toHaveCount(7)
->and($report['trendCurrentAssessment']['sampleCount'])->toBe(5)
->and($report['trendCurrentAssessment']['previousComparableRunRef'])->not->toBeNull()
->and($report['trendHistoryArtifact']['history'][0]['baselineSeconds'])->not->toBeNull()
->and($report['trendHistoryArtifact']['history'][0]['budgetSeconds'])->toEqual((float) $report['budgetThresholdSeconds'])
->and($report['trendCurrentAssessment']['summaryLine'])->not->toBe('')
->and($report['trendWarnings'])->toBeArray();
}
});

View File

@ -65,13 +65,11 @@
$this->actingAs($user)
->get('/admin/workspaces')
->assertOk()
->assertSee('Select workspace')
->assertSee('Select tenant')
->assertSee('Choose a workspace first.')
->assertDontSee('Search tenants…');
});
it('renders the tenant indicator read-only on tenant-scoped pages (Filament tenant menu is primary)', function (): void {
it('keeps tenant-scoped pages selector read-only while exposing the clear tenant scope action', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
@ -82,12 +80,10 @@
->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant)))
->assertOk()
->assertSee($tenant->getFilamentName())
->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant')
->assertDontSee('Clear tenant scope');
->assertSee('Clear tenant scope');
});
it('renders the routed tenant as read-only context on tenant resource view pages', function (): void {
it('renders routed tenant resource view pages with a clear tenant scope action but no inline selector list', function (): void {
$currentTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'YPTW2',
@ -117,9 +113,7 @@
->assertOk()
->assertSee($routedTenant->getFilamentName())
->assertSee('Switch tenant')
->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant')
->assertDontSee('Clear tenant scope');
->assertSee('Clear tenant scope');
});
it('filters the header tenant picker to tenants the user can access', function (): void {

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('shows the routed workspace and tenant truth on tenant-panel entry without relying on session workspace state', function (): void {
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Entry']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
session()->forget(WorkspaceContext::SESSION_KEY);
$this->actingAs($user)
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
->assertOk()
->assertSee($tenant->workspace()->firstOrFail()->name)
->assertSee('Tenant Panel Entry')
->assertSee('Switch tenant')
->assertSee('Clear tenant scope')
->assertDontSee('Search tenants…')
->assertDontSee('admin/select-tenant');
});
it('keeps workspace-scoped routes tenantless when a cross-workspace tenant hint is rejected', function (): void {
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Workspace Tenant']);
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Rejected Foreign Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceTenant->workspace_id])
->get(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]))
->assertOk()
->assertSee('No tenant selected')
->assertDontSee('Tenant scope: Rejected Foreign Tenant');
});

View File

@ -87,3 +87,43 @@
])
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 when selecting a tenant from another workspace', function (): void {
$activeTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
[$user, $activeTenant] = createUserWithTenant(tenant: $activeTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Workspace Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: $user, role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $activeTenant->workspace_id])
->post(route('admin.select-tenant'), [
'tenant_id' => (int) $foreignTenant->getKey(),
])
->assertNotFound();
expect(session()->get(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, []))
->not->toHaveKey((string) $activeTenant->workspace_id);
});
it('returns 404 when selecting a tenant the user cannot access', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $workspace->getKey(),
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('admin.select-tenant'), [
'tenant_id' => (int) $tenant->getKey(),
])
->assertNotFound();
});

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('ignores an intended tenant route that is not valid in the target workspace', function (): void {
$sourceTenant = Tenant::factory()->active()->create(['name' => 'Source Tenant']);
[$user, $sourceTenant] = createUserWithTenant(tenant: $sourceTenant, role: 'owner');
$targetWorkspaceTenant = Tenant::factory()->active()->create([
'name' => 'Target Tenant',
]);
createUserWithTenant(tenant: $targetWorkspaceTenant, user: $user, role: 'owner');
$targetWorkspace = $targetWorkspaceTenant->workspace()->firstOrFail();
$response = $this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $sourceTenant->workspace_id,
WorkspaceContext::INTENDED_URL_SESSION_KEY => "/admin/t/{$sourceTenant->external_id}",
])
->post(route('admin.switch-workspace'), [
'workspace_id' => (int) $targetWorkspace->getKey(),
]);
$response->assertRedirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $targetWorkspaceTenant));
expect(session(WorkspaceContext::SESSION_KEY))->toBe((int) $targetWorkspace->getKey());
});

View File

@ -119,3 +119,59 @@
expect($url)->toBe($expectedRoute);
});
it('preserves a safe intended admin url that targets a tenant in the selected workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'status' => 'active',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$intendedUrl = route('admin.operations.index', ['tenant' => $tenant->external_id]);
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
expect($url)->toBe($intendedUrl);
});
it('rejects an unsafe intended admin url when its tenant hint targets another workspace', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => $workspace->getKey(),
'user_id' => $user->getKey(),
'role' => 'owner',
]);
$tenant = Tenant::factory()->create([
'workspace_id' => $workspace->getKey(),
'status' => 'active',
]);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'owner'],
]);
$foreignTenant = Tenant::factory()->create([
'status' => 'active',
]);
$intendedUrl = route('admin.operations.index', ['tenant' => $foreignTenant->external_id]);
$url = $this->resolver->resolve($workspace, $user, $intendedUrl);
expect($url)->toBe(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
});

View File

@ -45,7 +45,7 @@
$this->actingAs($user)
->get('/admin/workspaces')
->assertOk()
->assertSee('Select workspace')
->assertSee('Choose workspace')
->assertSee('Choose a workspace first.');
});

View File

@ -367,4 +367,258 @@ public static function buildOutcomeRecord(
], static fn (mixed $value): bool => $value !== null);
}
}
public static function trendVarianceFloorSeconds(string $laneId, ?string $triggerClass = null): int
{
$matchingProfiles = array_values(array_filter(
self::enforcementProfiles(),
static fn (array $profile): bool => $profile['laneId'] === $laneId
&& ($triggerClass === null || $profile['triggerClass'] === $triggerClass),
));
if ($matchingProfiles === [] && $triggerClass !== null) {
$matchingProfiles = array_values(array_filter(
self::enforcementProfiles(),
static fn (array $profile): bool => $profile['laneId'] === $laneId,
));
}
if ($matchingProfiles !== []) {
return (int) max(array_map(
static fn (array $profile): int => (int) ($profile['varianceAllowanceSeconds'] ?? 0),
$matchingProfiles,
));
}
return match ($laneId) {
'junit' => 30,
'profiling' => 45,
default => 15,
};
}
public static function nearBudgetHeadroomSeconds(string $laneId): int
{
return match ($laneId) {
'fast-feedback' => 20,
'confidence', 'junit' => 45,
'browser' => 25,
'heavy-governance' => 30,
'profiling' => 120,
default => max(self::trendVarianceFloorSeconds($laneId), 15),
};
}
/**
* @return array<string, mixed>
*/
public static function recalibrationPolicy(string $laneId): array
{
return [
'laneId' => $laneId,
'baselineRequiresExplicitReview' => true,
'budgetRequiresExplicitReview' => true,
'minimumBaselineEvidenceSamples' => 3,
'minimumBudgetEvidenceSamples' => $laneId === 'fast-feedback' ? 4 : 5,
'baselineAllowedRationales' => [
'lane-scope-change',
'infrastructure-shift',
'post-improvement-reset',
'manual-hold',
],
'approvedBaselineRationales' => [
'lane-scope-change',
'infrastructure-shift',
'post-improvement-reset',
],
'budgetAllowedRationales' => [
'infrastructure-shift',
'sustained-erosion',
'manual-hold',
],
'approvedBudgetRationales' => [
'infrastructure-shift',
'sustained-erosion',
],
'rejectedRationales' => [
'noise-rejected',
'manual-hold',
],
];
}
/**
* @param list<array<string, mixed>> $historyRecords
* @return array<string, mixed>
*/
public static function buildRecalibrationDecisionRecord(
string $laneId,
string $targetType,
array $assessment,
array $historyRecords,
string $decisionStatus,
string $rationaleCode,
string $recordedIn,
?float $proposedValueSeconds = null,
?string $notes = null,
): array {
if (! in_array($targetType, ['baseline', 'budget'], true)) {
throw new InvalidArgumentException(sprintf('Unknown recalibration target type [%s].', $targetType));
}
if (! in_array($decisionStatus, ['candidate', 'approved', 'rejected'], true)) {
throw new InvalidArgumentException(sprintf('Unknown recalibration decision status [%s].', $decisionStatus));
}
$policy = self::recalibrationPolicy($laneId);
$minimumEvidenceSamples = $targetType === 'budget'
? (int) $policy['minimumBudgetEvidenceSamples']
: (int) $policy['minimumBaselineEvidenceSamples'];
$minimumEvidenceSamples = $decisionStatus === 'rejected'
? 1
: $minimumEvidenceSamples;
$evidenceRunRefs = array_values(array_filter(array_map(
static fn (array $record): ?string => is_string($record['runRef'] ?? null) && $record['runRef'] !== ''
? (string) $record['runRef']
: null,
$historyRecords,
)));
$evidenceRunRefs = array_slice(array_values(array_unique($evidenceRunRefs)), 0, $minimumEvidenceSamples);
if (count($evidenceRunRefs) < $minimumEvidenceSamples) {
throw new InvalidArgumentException(sprintf(
'Recalibration decisions for [%s] require at least %d evidence samples.',
$targetType,
$minimumEvidenceSamples,
));
}
if ($decisionStatus === 'approved') {
$allowedRationales = $targetType === 'baseline'
? $policy['approvedBaselineRationales']
: $policy['approvedBudgetRationales'];
if (! in_array($rationaleCode, $allowedRationales, true)) {
throw new InvalidArgumentException(sprintf(
'Approved %s recalibration decisions must use one of [%s].',
$targetType,
implode(', ', $allowedRationales),
));
}
} elseif ($decisionStatus === 'rejected') {
if (! in_array($rationaleCode, $policy['rejectedRationales'], true)) {
throw new InvalidArgumentException('Rejected recalibration decisions must use a rejected rationale.');
}
} else {
$allowedRationales = $targetType === 'baseline'
? $policy['baselineAllowedRationales']
: $policy['budgetAllowedRationales'];
if (! in_array($rationaleCode, $allowedRationales, true)) {
throw new InvalidArgumentException(sprintf(
'Candidate %s recalibration decisions must use one of [%s].',
$targetType,
implode(', ', $allowedRationales),
));
}
}
$currentRecord = $historyRecords[0] ?? [];
$previousValueSeconds = $targetType === 'baseline'
? (float) ($currentRecord['baselineSeconds'] ?? $currentRecord['wallClockSeconds'] ?? 0.0)
: (float) ($currentRecord['budgetSeconds'] ?? 0.0);
$defaultNotes = match ($decisionStatus) {
'approved' => sprintf(
'Approved %s recalibration for lane [%s] after reviewing %d comparable samples.',
$targetType,
$laneId,
count($evidenceRunRefs),
),
'rejected' => sprintf(
'Rejected %s recalibration for lane [%s] because current evidence is not strong enough to move repository truth.',
$targetType,
$laneId,
),
default => sprintf(
'Candidate %s recalibration for lane [%s]. Review the active spec or PR before changing repository truth.',
$targetType,
$laneId,
),
};
return [
'targetType' => $targetType,
'decisionStatus' => $decisionStatus,
'evidenceRunRefs' => $evidenceRunRefs,
'previousValueSeconds' => round($previousValueSeconds, 6),
'proposedValueSeconds' => $proposedValueSeconds !== null ? round($proposedValueSeconds, 6) : null,
'rationaleCode' => $rationaleCode,
'recordedIn' => $recordedIn,
'notes' => $notes ?? $defaultNotes,
];
}
/**
* @param list<array<string, mixed>> $historyRecords
* @return list<array<string, mixed>>
*/
public static function automaticRecalibrationDecisions(
string $laneId,
array $assessment,
array $historyRecords,
string $recordedIn,
): array {
$recommendation = (string) ($assessment['recalibrationRecommendation'] ?? 'none');
$windowStatus = (string) ($assessment['windowStatus'] ?? 'stable');
$currentRecord = $historyRecords[0] ?? [];
$decisionRecords = [];
if ($recommendation === 'review-baseline') {
$decisionRecords[] = self::buildRecalibrationDecisionRecord(
laneId: $laneId,
targetType: 'baseline',
assessment: $assessment,
historyRecords: $historyRecords,
decisionStatus: 'candidate',
rationaleCode: 'manual-hold',
recordedIn: $recordedIn,
proposedValueSeconds: isset($currentRecord['wallClockSeconds']) ? (float) $currentRecord['wallClockSeconds'] : null,
notes: 'Candidate baseline review. Confirm lane-scope, infrastructure, or post-improvement evidence before approving any baseline reset.',
);
}
if ($recommendation === 'review-budget') {
$proposedBudgetSeconds = isset($currentRecord['wallClockSeconds'])
? (float) $currentRecord['wallClockSeconds'] + self::nearBudgetHeadroomSeconds($laneId)
: null;
$decisionRecords[] = self::buildRecalibrationDecisionRecord(
laneId: $laneId,
targetType: 'budget',
assessment: $assessment,
historyRecords: $historyRecords,
decisionStatus: 'candidate',
rationaleCode: 'sustained-erosion',
recordedIn: $recordedIn,
proposedValueSeconds: $proposedBudgetSeconds,
notes: 'Candidate budget review. Only approve after sustained erosion is confirmed and the active spec or PR records why the budget should move.',
);
}
if ($decisionRecords === [] && in_array($windowStatus, ['insufficient-history', 'noisy', 'scope-changed'], true)) {
$decisionRecords[] = self::buildRecalibrationDecisionRecord(
laneId: $laneId,
targetType: 'budget',
assessment: $assessment,
historyRecords: $historyRecords,
decisionStatus: 'rejected',
rationaleCode: $windowStatus === 'noisy' ? 'noise-rejected' : 'manual-hold',
recordedIn: $recordedIn,
notes: 'Recalibration is rejected for this cycle because the comparison window is not stable enough to justify moving repository truth.',
);
}
return $decisionRecords;
}
}

View File

@ -56,7 +56,7 @@ final class TestLaneManifest
public static function manifest(): array
{
return [
'version' => 2,
'version' => 3,
'artifactDirectory' => self::artifactDirectory(),
'mainlineBranch' => self::mainlineBranch(),
'classifications' => self::classifications(),
@ -70,6 +70,8 @@ public static function manifest(): array
'laneBindings' => self::laneBindings(),
'budgetEnforcementProfiles' => TestLaneBudget::enforcementProfiles(),
'artifactPublicationContracts' => self::artifactPublicationContracts(),
'trendContractVersion' => self::laneTrendContractVersion(),
'laneTrendPolicies' => self::laneTrendPolicies(),
'failureClasses' => self::failureClasses(),
'familyBudgets' => self::familyBudgets(),
'heavyGovernanceBudgetContract' => self::heavyGovernanceBudgetContract(),
@ -2059,7 +2061,7 @@ public static function artifactPublicationContract(string $laneId): array
{
self::lane($laneId);
$requiredFiles = ['summary.md', 'budget.json', 'report.json', 'junit.xml'];
$requiredFiles = ['summary.md', 'budget.json', 'report.json', 'junit.xml', 'trend-history.json'];
$optionalFiles = $laneId === 'profiling' ? ['profile.txt'] : [];
$sourcePatterns = array_map(
static fn (string $artifactFile): string => sprintf('%s-latest.%s', $laneId, $artifactFile),
@ -2084,6 +2086,112 @@ public static function artifactPublicationContract(string $laneId): array
];
}
public static function laneTrendContractVersion(): string
{
return '1.0.0';
}
/**
* @return list<array<string, mixed>>
*/
public static function laneTrendPolicies(): array
{
return array_map(
static fn (array $lane): array => self::laneTrendPolicy((string) $lane['id']),
self::lanes(),
);
}
/**
* @return array<string, mixed>
*/
public static function laneTrendPolicy(string $laneId, ?string $workflowId = null, ?string $triggerClass = null): array
{
self::lane($laneId);
$policy = TestLaneBudget::recalibrationPolicy($laneId);
$workflowProfile = null;
if ($workflowId !== null && $workflowId !== '') {
try {
$workflowProfile = self::workflowProfile($workflowId);
} catch (InvalidArgumentException) {
$workflowProfile = null;
}
}
$workflowProfile ??= self::workflowProfilesForLane($laneId)[0] ?? null;
$resolvedTriggerClass = $triggerClass
?? (is_array($workflowProfile) ? (string) ($workflowProfile['triggerClass'] ?? '') : '');
return [
'retentionLimit' => in_array($laneId, ['fast-feedback', 'confidence', 'browser', 'heavy-governance'], true) ? 20 : 10,
'comparisonWindowSize' => $laneId === 'profiling' ? 4 : 5,
'minimumComparableSamples' => 3,
'varianceFloorSeconds' => TestLaneBudget::trendVarianceFloorSeconds($laneId, $resolvedTriggerClass !== '' ? $resolvedTriggerClass : null),
'nearBudgetHeadroomSeconds' => TestLaneBudget::nearBudgetHeadroomSeconds($laneId),
'hotspotFamilyLimit' => 5,
'hotspotFileLimit' => 3,
'slowestEntryRetention' => 10,
'recalibrationPolicy' => [
'baselineRequiresExplicitReview' => (bool) $policy['baselineRequiresExplicitReview'],
'budgetRequiresExplicitReview' => (bool) $policy['budgetRequiresExplicitReview'],
'minimumBudgetEvidenceSamples' => (int) $policy['minimumBudgetEvidenceSamples'],
],
];
}
public static function laneScopeSignature(string $laneId): string
{
$lane = self::lane($laneId);
$payload = [
'laneId' => $laneId,
'governanceClass' => $lane['governanceClass'],
'parallelMode' => $lane['parallelMode'],
'includedFamilies' => $lane['includedFamilies'],
'excludedFamilies' => $lane['excludedFamilies'],
'selectors' => $lane['selectors'],
'artifacts' => $lane['artifacts'],
'budget' => [
'baselineSource' => $lane['budget']['baselineSource'],
'thresholdSeconds' => $lane['budget']['thresholdSeconds'],
],
'contractVersion' => self::laneTrendContractVersion(),
];
return sha1(json_encode($payload, JSON_THROW_ON_ERROR));
}
/**
* @return array<string, string>
*/
public static function comparisonFingerprintInputs(string $laneId, ?string $workflowId = null, ?string $triggerClass = null): array
{
$lane = self::lane($laneId);
$workflowProfile = null;
if ($workflowId !== null && $workflowId !== '') {
try {
$workflowProfile = self::workflowProfile($workflowId);
} catch (InvalidArgumentException) {
$workflowProfile = null;
}
}
$workflowProfile ??= self::workflowProfilesForLane($laneId)[0] ?? null;
return [
'laneId' => $laneId,
'workflowId' => $workflowId
?? (is_array($workflowProfile) ? (string) ($workflowProfile['workflowId'] ?? '') : sprintf('local-%s', $laneId)),
'triggerClass' => $triggerClass
?? (is_array($workflowProfile) ? (string) ($workflowProfile['triggerClass'] ?? '') : 'local'),
'contractVersion' => self::laneTrendContractVersion(),
'baselineSource' => (string) ($lane['budget']['baselineSource'] ?? 'measured-lane'),
'laneScopeSignature' => self::laneScopeSignature($laneId),
];
}
/**
* @return list<array<string, mixed>>
*/
@ -3199,4 +3307,4 @@ private static function familyMatchScore(array $family, string $filePath): int
return $score;
}
}
}

View File

@ -5,11 +5,12 @@
namespace Tests\Support;
use SimpleXMLElement;
use ZipArchive;
final class TestLaneReport
{
/**
* @return array{junit: string, summary: string, budget: string, report: string, profile: string}
* @return array{junit: string, summary: string, budget: string, report: string, profile: string, trendHistory: string}
*/
public static function artifactPaths(string $laneId, ?string $artifactDirectory = null): array
{
@ -21,6 +22,7 @@ public static function artifactPaths(string $laneId, ?string $artifactDirectory
'budget' => sprintf('%s/%s-latest.budget.json', $directory, $laneId),
'report' => sprintf('%s/%s-latest.report.json', $directory, $laneId),
'profile' => sprintf('%s/%s-latest.profile.txt', $directory, $laneId),
'trendHistory' => sprintf('%s/%s-latest.trend-history.json', $directory, $laneId),
];
}
@ -205,6 +207,92 @@ public static function stageArtifacts(string $laneId, string $stagingDirectory,
];
}
/**
* @return array<string, mixed>
*/
public static function hydrateTrendHistory(
string $laneId,
?string $historyFile = null,
?string $bundlePath = null,
?string $artifactDirectory = null,
): array {
$artifactPaths = self::artifactPaths($laneId, $artifactDirectory);
$targetPath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']);
self::ensureDirectory(dirname($targetPath));
$resolvedHistoryFile = is_string($historyFile) && trim($historyFile) !== ''
? self::resolveInputPath($historyFile)
: null;
$resolvedBundlePath = is_string($bundlePath) && trim($bundlePath) !== ''
? self::resolveInputPath($bundlePath)
: null;
if (is_string($resolvedHistoryFile) && is_file($resolvedHistoryFile)) {
copy($resolvedHistoryFile, $targetPath);
return [
'laneId' => $laneId,
'targetPath' => $targetPath,
'hydrated' => true,
'sourceType' => 'history-file',
'sourcePath' => $resolvedHistoryFile,
];
}
if (is_string($resolvedBundlePath) && $resolvedBundlePath !== '') {
if (is_dir($resolvedBundlePath)) {
$bundleHistoryPath = self::findTrendHistoryInDirectory($laneId, $resolvedBundlePath);
if (is_string($bundleHistoryPath)) {
copy($bundleHistoryPath, $targetPath);
return [
'laneId' => $laneId,
'targetPath' => $targetPath,
'hydrated' => true,
'sourceType' => 'bundle-directory',
'sourcePath' => $bundleHistoryPath,
];
}
} elseif (is_file($resolvedBundlePath) && str_ends_with(strtolower($resolvedBundlePath), '.zip')) {
$zip = new ZipArchive();
if ($zip->open($resolvedBundlePath) === true) {
$entryName = self::findTrendHistoryInZip($laneId, $zip);
if (is_string($entryName)) {
$contents = $zip->getFromName($entryName);
$zip->close();
if (is_string($contents) && $contents !== '') {
file_put_contents($targetPath, $contents);
return [
'laneId' => $laneId,
'targetPath' => $targetPath,
'hydrated' => true,
'sourceType' => 'bundle-zip',
'sourcePath' => $resolvedBundlePath,
'sourceEntry' => $entryName,
];
}
}
$zip->close();
}
}
}
return [
'laneId' => $laneId,
'targetPath' => $targetPath,
'hydrated' => false,
'sourceType' => null,
'sourcePath' => $resolvedHistoryFile ?? $resolvedBundlePath,
];
}
/**
* @return array{slowestEntries: list<array<string, mixed>>, durationsByFile: array<string, float>}
*/
@ -426,12 +514,19 @@ classificationAttribution: $attribution['classificationAttribution'],
$report['ciBudgetEvaluation'] = $ciBudgetEvaluation;
}
$trendHistoryArtifact = self::buildTrendHistoryArtifact($report, $artifactPaths);
$report['trendHistoryArtifact'] = $trendHistoryArtifact;
$report['trendCurrentAssessment'] = $trendHistoryArtifact['currentAssessment'];
$report['trendHotspotSnapshot'] = $trendHistoryArtifact['hotspotSnapshot'] ?? null;
$report['trendRecalibrationDecisions'] = $trendHistoryArtifact['recalibrationDecisions'] ?? [];
$report['trendWarnings'] = $trendHistoryArtifact['warnings'] ?? [];
return $report;
}
/**
* @param array<string, mixed> $report
* @return array{summary: string, budget: string, report: string, profile: string}
* @return array{summary: string, budget: string, report: string, profile: string, trendHistory: string}
*/
public static function writeArtifacts(
string $laneId,
@ -463,6 +558,11 @@ public static function writeArtifacts(
json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
);
file_put_contents(
TestLaneManifest::absolutePath($artifactPaths['trendHistory']),
json_encode($report['trendHistoryArtifact'] ?? [], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
);
$report['artifactPublication'] = self::artifactPublicationStatus($laneId, $artifactDirectory);
$report['ciSummary'] = self::buildCiSummary(
report: $report,
@ -531,6 +631,18 @@ private static function buildSummaryMarkdown(array $report): string
sprintf('- Wall clock: %.2f seconds', (float) $report['wallClockSeconds']),
sprintf('- Budget: %d seconds (%s)', (int) $report['budgetThresholdSeconds'], $report['budgetStatus']),
];
$trendHistory = is_array($report['trendHistoryArtifact'] ?? null)
? $report['trendHistoryArtifact']
: null;
$trendAssessment = is_array($report['trendCurrentAssessment'] ?? null)
? $report['trendCurrentAssessment']
: ($trendHistory['currentAssessment'] ?? null);
$currentTrendRecord = is_array($trendHistory['history'][0] ?? null)
? $trendHistory['history'][0]
: null;
$previousTrendRecord = is_array($trendHistory['history'][1] ?? null)
? $trendHistory['history'][1]
: null;
if (isset($report['ciSummary']) && is_array($report['ciSummary'])) {
$lines[] = sprintf(
@ -584,6 +696,70 @@ private static function buildSummaryMarkdown(array $report): string
);
}
$lines[] = '';
$lines[] = '## Lane trend';
if (is_array($trendAssessment)) {
$previousRuntime = is_array($previousTrendRecord)
? sprintf('%.2fs', (float) ($previousTrendRecord['wallClockSeconds'] ?? 0.0))
: 'n/a';
$baselineRuntime = isset($currentTrendRecord['baselineSeconds']) && $currentTrendRecord['baselineSeconds'] !== null
? sprintf('%.2fs', (float) $currentTrendRecord['baselineSeconds'])
: 'n/a';
$lines[] = sprintf(
'- Window: current %.2fs | previous %s | baseline %s | budget %.2fs',
(float) $report['wallClockSeconds'],
$previousRuntime,
$baselineRuntime,
(float) $report['budgetThresholdSeconds'],
);
$lines[] = sprintf('- Health class: %s', (string) $trendAssessment['healthClass']);
$lines[] = sprintf('- Window status: %s', (string) $trendAssessment['windowStatus']);
$lines[] = sprintf('- Recalibration recommendation: %s', (string) $trendAssessment['recalibrationRecommendation']);
$lines[] = sprintf('- Budget headroom: %.2fs', (float) $trendAssessment['budgetHeadroomSeconds']);
$lines[] = sprintf('- Summary: %s', (string) $trendAssessment['summaryLine']);
if (array_key_exists('deltaToPreviousSeconds', $trendAssessment) && $trendAssessment['deltaToPreviousSeconds'] !== null) {
$lines[] = sprintf('- Delta to previous: %+0.2fs', (float) $trendAssessment['deltaToPreviousSeconds']);
}
if (array_key_exists('deltaToBaselineSeconds', $trendAssessment) && $trendAssessment['deltaToBaselineSeconds'] !== null) {
$lines[] = sprintf('- Delta to baseline: %+0.2fs', (float) $trendAssessment['deltaToBaselineSeconds']);
}
} else {
$lines[] = '- Trend assessment unavailable.';
}
$hotspotSnapshot = is_array($report['trendHotspotSnapshot'] ?? null)
? $report['trendHotspotSnapshot']
: ($trendHistory['hotspotSnapshot'] ?? null);
if (is_array($hotspotSnapshot)) {
$lines[] = sprintf('- Hotspot evidence: %s', (string) $hotspotSnapshot['evidenceAvailability']);
foreach (array_slice($hotspotSnapshot['familyDeltas'] ?? [], 0, 3) as $delta) {
$lines[] = sprintf(
'- Family delta: %s %+0.2fs',
(string) $delta['name'],
(float) $delta['deltaSeconds'],
);
}
}
foreach ($report['trendRecalibrationDecisions'] ?? [] as $decision) {
$lines[] = sprintf(
'- Recalibration: %s %s (%s)',
(string) $decision['targetType'],
(string) $decision['decisionStatus'],
(string) $decision['rationaleCode'],
);
}
foreach ($report['trendWarnings'] ?? [] as $warning) {
$lines[] = sprintf('- Warning: %s', $warning);
}
$lines[] = '';
$lines[] = '## Slowest entries';
@ -929,6 +1105,11 @@ private static function budgetPayload(array $report): array
'remainingOpenFamilies' => $report['remainingOpenFamilies'] ?? null,
'stabilizedFamilies' => $report['stabilizedFamilies'] ?? null,
'sharedFixtureSlimmingComparison' => $report['sharedFixtureSlimmingComparison'] ?? null,
'trendHistoryArtifact' => $report['trendHistoryArtifact'] ?? null,
'trendCurrentAssessment' => $report['trendCurrentAssessment'] ?? null,
'trendHotspotSnapshot' => $report['trendHotspotSnapshot'] ?? null,
'trendRecalibrationDecisions' => $report['trendRecalibrationDecisions'] ?? null,
'trendWarnings' => $report['trendWarnings'] ?? null,
'ciBudgetEvaluation' => $report['ciBudgetEvaluation'] ?? null,
'artifactPublication' => $report['artifactPublication'] ?? null,
'ciSummary' => $report['ciSummary'] ?? null,
@ -936,7 +1117,718 @@ private static function budgetPayload(array $report): array
}
/**
* @param array{junit: string, summary: string, budget: string, report: string, profile: string} $artifactPaths
* @param array<string, mixed> $report
* @param array{junit: string, summary: string, budget: string, report: string, profile: string, trendHistory: string} $artifactPaths
* @return array<string, mixed>
*/
private static function buildTrendHistoryArtifact(array $report, array $artifactPaths): array
{
$laneId = (string) $report['laneId'];
$existingArtifact = self::loadTrendHistoryArtifact($laneId, (string) $report['artifactDirectory']);
$existingDecisions = array_values(array_filter(
$existingArtifact['recalibrationDecisions'] ?? [],
static fn (mixed $decision): bool => is_array($decision),
));
$policy = TestLaneManifest::laneTrendPolicy(
$laneId,
$report['ciContext']['workflowId'] ?? null,
$report['ciContext']['triggerClass'] ?? null,
);
$baselineReference = self::resolveBaselineReference($report, $existingArtifact['history'] ?? [], $existingDecisions);
$currentRecord = self::buildTrendRecord($report, $artifactPaths, $baselineReference);
$history = self::mergeTrendHistory($currentRecord, $existingArtifact['history'] ?? [], (int) $policy['retentionLimit']);
$comparisonWindow = self::buildComparisonWindow($currentRecord, $history, $policy);
$assessment = self::buildTrendAssessment($currentRecord, $comparisonWindow, $policy);
$hotspotSnapshot = self::buildHotspotSnapshot(
$currentRecord,
$comparisonWindow['previousComparableRecord'] ?? null,
$policy,
);
$recordedIn = 'specs/211-runtime-trend-recalibration/spec.md';
$recalibrationDecisions = self::mergeRecalibrationDecisions(
$existingDecisions,
TestLaneBudget::automaticRecalibrationDecisions($laneId, $assessment, $history, $recordedIn),
);
$warnings = self::buildTrendWarnings($assessment, $hotspotSnapshot, $comparisonWindow);
return [
'schemaVersion' => TestLaneManifest::laneTrendContractVersion(),
'laneId' => $laneId,
'workflowProfile' => (string) $currentRecord['workflowId'],
'generatedAt' => (string) $report['finishedAt'],
'policy' => $policy,
'history' => $history,
'currentAssessment' => $assessment,
'hotspotSnapshot' => $hotspotSnapshot,
'recalibrationDecisions' => $recalibrationDecisions,
'warnings' => $warnings,
];
}
/**
* @param list<array<string, mixed>> $history
* @param list<array<string, mixed>> $recalibrationDecisions
* @return array{seconds: float|null, source: string|null}
*/
private static function resolveBaselineReference(array $report, array $history, array $recalibrationDecisions): array
{
foreach ($recalibrationDecisions as $decision) {
if (($decision['targetType'] ?? null) !== 'baseline' || ($decision['decisionStatus'] ?? null) !== 'approved') {
continue;
}
if (($decision['proposedValueSeconds'] ?? null) === null) {
continue;
}
return [
'seconds' => round((float) $decision['proposedValueSeconds'], 6),
'source' => (string) ($decision['recordedIn'] ?? 'approved-baseline'),
];
}
if (is_array($report['sharedFixtureSlimmingComparison'] ?? null)) {
return [
'seconds' => round((float) $report['sharedFixtureSlimmingComparison']['baselineSeconds'], 6),
'source' => (string) ($report['sharedFixtureSlimmingComparison']['comparisonProfile'] ?? 'shared-fixture-baseline'),
];
}
foreach (array_reverse($history) as $record) {
if (! is_array($record)) {
continue;
}
if (($record['baselineSeconds'] ?? null) !== null) {
return [
'seconds' => round((float) $record['baselineSeconds'], 6),
'source' => (string) ($record['baselineSource'] ?? 'trend-history-anchor'),
];
}
if (($record['wallClockSeconds'] ?? null) !== null) {
return [
'seconds' => round((float) $record['wallClockSeconds'], 6),
'source' => 'trend-history-anchor',
];
}
}
return [
'seconds' => null,
'source' => null,
];
}
/**
* @param array<string, mixed> $report
* @param array{junit: string, summary: string, budget: string, report: string, profile: string, trendHistory: string} $artifactPaths
* @param array{seconds: float|null, source: string|null} $baselineReference
* @return array<string, mixed>
*/
private static function buildTrendRecord(array $report, array $artifactPaths, array $baselineReference): array
{
$laneId = (string) $report['laneId'];
$workflowId = (string) ($report['ciContext']['workflowId'] ?? sprintf('local-%s', $laneId));
$triggerClass = (string) ($report['ciContext']['triggerClass'] ?? 'local');
$runRef = self::currentRunRef($laneId, (string) $report['finishedAt'], (float) $report['wallClockSeconds']);
$budgetEvaluation = is_array($report['ciBudgetEvaluation'] ?? null)
? $report['ciBudgetEvaluation']
: [
'budgetStatus' => (string) ($report['budgetStatus'] ?? 'within-budget'),
'blockingStatus' => 'informational',
];
return array_filter([
'runRef' => $runRef,
'laneId' => $laneId,
'workflowId' => $workflowId,
'triggerClass' => $triggerClass,
'generatedAt' => (string) $report['finishedAt'],
'wallClockSeconds' => round((float) $report['wallClockSeconds'], 6),
'baselineSeconds' => $baselineReference['seconds'] !== null ? round((float) $baselineReference['seconds'], 6) : null,
'baselineSource' => $baselineReference['source'],
'budgetSeconds' => round((float) $report['budgetThresholdSeconds'], 6),
'budgetStatus' => (string) ($budgetEvaluation['budgetStatus'] ?? $report['budgetStatus'] ?? 'within-budget'),
'blockingStatus' => (string) ($budgetEvaluation['blockingStatus'] ?? 'informational'),
'comparisonFingerprint' => self::comparisonFingerprint($laneId, $workflowId, $triggerClass),
'classificationTotals' => self::runtimeBucketsFromAttribution(
$report['classificationAttribution'] ?? [],
'classificationId',
),
'familyTotals' => self::runtimeBucketsFromAttribution(
$report['familyAttribution'] ?? [],
'familyId',
),
'hotspotFiles' => self::hotspotFileBuckets($report['slowestEntries'] ?? []),
'slowestEntries' => self::trendSlowestEntries($report['slowestEntries'] ?? []),
'artifactRefs' => [
'summary' => $artifactPaths['summary'],
'report' => $artifactPaths['report'],
'budget' => $artifactPaths['budget'],
'junit' => $artifactPaths['junit'],
'trendHistory' => $artifactPaths['trendHistory'],
],
], static fn (mixed $value): bool => $value !== null);
}
private static function currentRunRef(string $laneId, string $finishedAt, float $wallClockSeconds): string
{
$ciRunId = getenv('GITEA_RUN_ID') ?: getenv('GITHUB_RUN_ID') ?: null;
if (is_string($ciRunId) && $ciRunId !== '') {
return sprintf('%s-%s', $laneId, $ciRunId);
}
return sprintf(
'%s-%s-%s',
$laneId,
str_replace([':', '+'], ['-', '_'], $finishedAt),
str_replace('.', '-', sprintf('%0.6f', $wallClockSeconds)),
);
}
private static function comparisonFingerprint(string $laneId, string $workflowId, string $triggerClass): string
{
$inputs = TestLaneManifest::comparisonFingerprintInputs($laneId, $workflowId, $triggerClass);
return sha1(json_encode($inputs, JSON_THROW_ON_ERROR));
}
/**
* @param array<string, mixed> $currentRecord
* @param list<array<string, mixed>> $existingHistory
* @return list<array<string, mixed>>
*/
private static function mergeTrendHistory(array $currentRecord, array $existingHistory, int $retentionLimit): array
{
$merged = [$currentRecord];
$seenRunRefs = [(string) $currentRecord['runRef'] => true];
foreach ($existingHistory as $record) {
if (! is_array($record)) {
continue;
}
$runRef = (string) ($record['runRef'] ?? '');
if ($runRef === '' || array_key_exists($runRef, $seenRunRefs)) {
continue;
}
$merged[] = $record;
$seenRunRefs[$runRef] = true;
if (count($merged) >= $retentionLimit) {
break;
}
}
return $merged;
}
/**
* @param array<string, mixed> $currentRecord
* @param list<array<string, mixed>> $history
* @param array<string, mixed> $policy
* @return array<string, mixed>
*/
private static function buildComparisonWindow(array $currentRecord, array $history, array $policy): array
{
$comparableRecords = [];
$excludedRecords = [];
$fingerprint = (string) $currentRecord['comparisonFingerprint'];
$comparisonWindowSize = (int) $policy['comparisonWindowSize'];
foreach ($history as $record) {
if (($record['comparisonFingerprint'] ?? null) !== $fingerprint) {
$excludedRecords[] = [
'runRef' => (string) ($record['runRef'] ?? 'unknown'),
'comparisonFingerprint' => (string) ($record['comparisonFingerprint'] ?? ''),
];
continue;
}
$comparableRecords[] = $record;
if (count($comparableRecords) >= $comparisonWindowSize) {
break;
}
}
$windowStatus = 'stable';
if (count($comparableRecords) < (int) $policy['minimumComparableSamples']) {
$windowStatus = $excludedRecords !== []
? 'scope-changed'
: 'insufficient-history';
}
return [
'currentRecord' => $currentRecord,
'previousComparableRecord' => $comparableRecords[1] ?? null,
'comparableRecords' => $comparableRecords,
'excludedRecords' => $excludedRecords,
'windowStatus' => $windowStatus,
'sampleCount' => count($comparableRecords),
];
}
/**
* @param array<string, mixed> $currentRecord
* @param array<string, mixed> $comparisonWindow
* @param array<string, mixed> $policy
* @return array<string, mixed>
*/
private static function buildTrendAssessment(array $currentRecord, array $comparisonWindow, array $policy): array
{
$comparableRecords = $comparisonWindow['comparableRecords'];
$previousComparableRecord = $comparisonWindow['previousComparableRecord'];
$sampleCount = (int) $comparisonWindow['sampleCount'];
$varianceFloorSeconds = (float) $policy['varianceFloorSeconds'];
$nearBudgetHeadroomSeconds = (float) $policy['nearBudgetHeadroomSeconds'];
$currentSeconds = (float) $currentRecord['wallClockSeconds'];
$budgetSeconds = (float) $currentRecord['budgetSeconds'];
$budgetHeadroomSeconds = round($budgetSeconds - $currentSeconds, 6);
$previousSeconds = is_array($previousComparableRecord)
? (float) ($previousComparableRecord['wallClockSeconds'] ?? 0.0)
: null;
$baselineSeconds = ($currentRecord['baselineSeconds'] ?? null) !== null
? (float) $currentRecord['baselineSeconds']
: null;
$deltaToPreviousSeconds = $previousSeconds !== null
? round($currentSeconds - $previousSeconds, 6)
: null;
$deltaToBaselineSeconds = $baselineSeconds !== null
? round($currentSeconds - $baselineSeconds, 6)
: null;
$deltaToPreviousPercent = $previousSeconds !== null && $previousSeconds > 0.0
? round(($deltaToPreviousSeconds / $previousSeconds) * 100, 6)
: null;
$deltaToBaselinePercent = $baselineSeconds !== null && $baselineSeconds > 0.0
? round(($deltaToBaselineSeconds / $baselineSeconds) * 100, 6)
: null;
$worseningStreak = self::worseningStreak($comparableRecords, $varianceFloorSeconds);
$varianceObservedSeconds = self::varianceObservedSeconds($comparableRecords);
$windowStatus = (string) $comparisonWindow['windowStatus'];
$noiseDetected = self::isNoisyWindow($comparableRecords, $varianceFloorSeconds);
$stablePlateau = $sampleCount >= (int) $policy['minimumComparableSamples']
&& $varianceObservedSeconds <= $varianceFloorSeconds
&& ($deltaToPreviousSeconds === null || abs($deltaToPreviousSeconds) <= $varianceFloorSeconds)
&& $deltaToBaselineSeconds !== null
&& abs($deltaToBaselineSeconds) > $varianceFloorSeconds;
$healthClass = 'healthy';
$recalibrationRecommendation = 'none';
$summaryLine = 'Lane runtime is stable and comfortably inside the documented budget.';
if ($windowStatus !== 'stable') {
$healthClass = 'unstable';
$recalibrationRecommendation = 'investigate';
$summaryLine = $windowStatus === 'scope-changed'
? 'Lane scope or workflow context changed, so older runs are not directly comparable yet.'
: 'Lane history is still building, so trend classification remains intentionally unstable.';
} elseif ($noiseDetected) {
$healthClass = 'unstable';
$windowStatus = 'noisy';
$recalibrationRecommendation = 'investigate';
$summaryLine = 'Recent samples disagree with each other, so the latest spike is treated as noise instead of a structural regression.';
} elseif ($budgetHeadroomSeconds < 0.0 && $worseningStreak >= 2) {
$healthClass = 'regressed';
$recalibrationRecommendation = 'review-budget';
$summaryLine = 'Comparable runs show repeated worsening and the lane is now over budget.';
} elseif ($worseningStreak >= 2) {
$healthClass = 'trending-worse';
$recalibrationRecommendation = $budgetHeadroomSeconds <= $nearBudgetHeadroomSeconds ? 'review-budget' : 'investigate';
$summaryLine = 'Comparable runs show sustained worsening above the documented variance floor.';
} elseif ($budgetHeadroomSeconds <= $nearBudgetHeadroomSeconds) {
$healthClass = 'budget-near';
$recalibrationRecommendation = 'investigate';
$summaryLine = 'Lane runtime remains under budget, but headroom is now thin enough to warrant attention.';
} elseif ($stablePlateau) {
$recalibrationRecommendation = 'review-baseline';
$summaryLine = 'Lane runtime has stabilized at a new level, so baseline review is reasonable if scope or infrastructure truth changed.';
}
return [
'healthClass' => $healthClass,
'recalibrationRecommendation' => $recalibrationRecommendation,
'budgetHeadroomSeconds' => $budgetHeadroomSeconds,
'deltaToPreviousSeconds' => $deltaToPreviousSeconds,
'deltaToPreviousPercent' => $deltaToPreviousPercent,
'deltaToBaselineSeconds' => $deltaToBaselineSeconds,
'deltaToBaselinePercent' => $deltaToBaselinePercent,
'worseningStreak' => $worseningStreak,
'varianceObservedSeconds' => $varianceObservedSeconds,
'windowStatus' => $windowStatus,
'sampleCount' => $sampleCount,
'previousComparableRunRef' => is_array($previousComparableRecord)
? (string) ($previousComparableRecord['runRef'] ?? '')
: null,
'summaryLine' => $summaryLine,
];
}
/**
* @param array<string, mixed> $currentRecord
* @param array<string, mixed>|null $previousComparableRecord
* @param array<string, mixed> $policy
* @return array<string, mixed>
*/
private static function buildHotspotSnapshot(array $currentRecord, ?array $previousComparableRecord, array $policy): array
{
if (! is_array($previousComparableRecord)
|| ($currentRecord['familyTotals'] ?? []) === []
|| ($previousComparableRecord['familyTotals'] ?? []) === []) {
return [
'evidenceAvailability' => 'unavailable',
'familyDeltas' => [],
'fileHotspots' => [],
'newEntrants' => [],
'droppedEntrants' => [],
];
}
$familyDeltas = self::deltaBuckets(
$currentRecord['familyTotals'],
$previousComparableRecord['familyTotals'] ?? [],
(int) $policy['hotspotFamilyLimit'],
);
$fileHotspots = self::deltaBuckets(
$currentRecord['hotspotFiles'] ?? [],
$previousComparableRecord['hotspotFiles'] ?? [],
(int) $policy['hotspotFileLimit'],
);
$currentHotspots = array_column($fileHotspots, 'name');
$previousHotspots = array_map(
static fn (array $bucket): string => (string) ($bucket['name'] ?? ''),
array_slice($previousComparableRecord['hotspotFiles'] ?? [], 0, (int) $policy['hotspotFileLimit']),
);
return [
'evidenceAvailability' => 'available',
'familyDeltas' => $familyDeltas,
'fileHotspots' => $fileHotspots,
'newEntrants' => array_values(array_diff($currentHotspots, $previousHotspots)),
'droppedEntrants' => array_values(array_diff($previousHotspots, $currentHotspots)),
];
}
/**
* @param array<string, mixed> $assessment
* @param array<string, mixed> $hotspotSnapshot
* @param array<string, mixed> $comparisonWindow
* @return list<string>
*/
private static function buildTrendWarnings(array $assessment, array $hotspotSnapshot, array $comparisonWindow): array
{
$warnings = [];
if (($assessment['windowStatus'] ?? 'stable') !== 'stable') {
$warnings[] = sprintf('Trend window status is %s.', (string) $assessment['windowStatus']);
}
if (($hotspotSnapshot['evidenceAvailability'] ?? 'unavailable') !== 'available') {
$warnings[] = 'Hotspot evidence is unavailable for this cycle.';
}
if (($comparisonWindow['excludedRecords'] ?? []) !== []) {
$warnings[] = 'One or more recent records were excluded because the comparison fingerprint changed.';
}
return $warnings;
}
/**
* @param list<array<string, mixed>> $existingDecisions
* @param list<array<string, mixed>> $newDecisions
* @return list<array<string, mixed>>
*/
private static function mergeRecalibrationDecisions(array $existingDecisions, array $newDecisions): array
{
$merged = [];
$seen = [];
foreach (array_merge($newDecisions, $existingDecisions) as $decision) {
if (! is_array($decision)) {
continue;
}
$signature = implode('|', [
(string) ($decision['targetType'] ?? ''),
(string) ($decision['decisionStatus'] ?? ''),
(string) ($decision['rationaleCode'] ?? ''),
(string) ($decision['recordedIn'] ?? ''),
]);
if (isset($seen[$signature])) {
continue;
}
$merged[] = $decision;
$seen[$signature] = true;
if (count($merged) >= 6) {
break;
}
}
return $merged;
}
/**
* @param list<array<string, mixed>> $attribution
* @return list<array<string, mixed>>
*/
private static function runtimeBucketsFromAttribution(array $attribution, string $nameKey): array
{
return array_values(array_map(
static fn (array $entry): array => [
'name' => (string) ($entry[$nameKey] ?? 'unknown'),
'runtimeSeconds' => round((float) ($entry['totalWallClockSeconds'] ?? 0.0), 6),
],
$attribution,
));
}
/**
* @param list<array<string, mixed>> $slowestEntries
* @return list<array<string, mixed>>
*/
private static function hotspotFileBuckets(array $slowestEntries): array
{
$durations = [];
foreach ($slowestEntries as $entry) {
$file = (string) ($entry['filePath'] ?? '');
if ($file === '') {
continue;
}
$durations[$file] = round(($durations[$file] ?? 0.0) + (float) ($entry['wallClockSeconds'] ?? $entry['durationSeconds'] ?? 0.0), 6);
}
arsort($durations);
return array_values(array_map(
static fn (string $file, float $seconds): array => [
'name' => $file,
'runtimeSeconds' => $seconds,
],
array_keys($durations),
$durations,
));
}
/**
* @param list<array<string, mixed>> $slowestEntries
* @return list<array<string, mixed>>
*/
private static function trendSlowestEntries(array $slowestEntries): array
{
return array_values(array_map(
static fn (array $entry): array => [
'label' => (string) ($entry['label'] ?? $entry['subject'] ?? 'unknown'),
'runtimeSeconds' => round((float) ($entry['wallClockSeconds'] ?? $entry['durationSeconds'] ?? 0.0), 6),
'file' => isset($entry['filePath']) ? (string) $entry['filePath'] : null,
],
array_slice($slowestEntries, 0, 10),
));
}
/**
* @param list<array<string, mixed>> $currentBuckets
* @param list<array<string, mixed>> $previousBuckets
* @return list<array<string, mixed>>
*/
private static function deltaBuckets(array $currentBuckets, array $previousBuckets, int $limit): array
{
$currentMap = [];
$previousMap = [];
foreach ($currentBuckets as $bucket) {
$currentMap[(string) ($bucket['name'] ?? '')] = (float) ($bucket['runtimeSeconds'] ?? 0.0);
}
foreach ($previousBuckets as $bucket) {
$previousMap[(string) ($bucket['name'] ?? '')] = (float) ($bucket['runtimeSeconds'] ?? 0.0);
}
$names = array_values(array_filter(array_unique(array_merge(array_keys($currentMap), array_keys($previousMap)))));
$deltas = [];
foreach ($names as $name) {
$currentSeconds = round((float) ($currentMap[$name] ?? 0.0), 6);
$previousSeconds = round((float) ($previousMap[$name] ?? 0.0), 6);
$deltaSeconds = round($currentSeconds - $previousSeconds, 6);
$deltaPercent = $previousSeconds > 0.0
? round(($deltaSeconds / $previousSeconds) * 100, 6)
: null;
$deltas[] = [
'name' => $name,
'currentSeconds' => $currentSeconds,
'previousSeconds' => $previousSeconds,
'deltaSeconds' => $deltaSeconds,
'deltaPercent' => $deltaPercent,
'direction' => $deltaSeconds > 0.0 ? 'up' : ($deltaSeconds < 0.0 ? 'down' : 'flat'),
];
}
usort($deltas, static fn (array $left, array $right): int => abs((float) $right['deltaSeconds']) <=> abs((float) $left['deltaSeconds']));
return array_slice($deltas, 0, $limit);
}
/**
* @param list<array<string, mixed>> $comparableRecords
*/
private static function worseningStreak(array $comparableRecords, float $varianceFloorSeconds): int
{
$streak = 0;
for ($index = 0; $index < count($comparableRecords) - 1; $index++) {
$currentSeconds = (float) ($comparableRecords[$index]['wallClockSeconds'] ?? 0.0);
$previousSeconds = (float) ($comparableRecords[$index + 1]['wallClockSeconds'] ?? 0.0);
if (($currentSeconds - $previousSeconds) <= $varianceFloorSeconds) {
break;
}
$streak++;
}
return $streak;
}
/**
* @param list<array<string, mixed>> $comparableRecords
*/
private static function varianceObservedSeconds(array $comparableRecords): float
{
$seconds = array_values(array_map(
static fn (array $record): float => (float) ($record['wallClockSeconds'] ?? 0.0),
$comparableRecords,
));
if ($seconds === []) {
return 0.0;
}
return round(max($seconds) - min($seconds), 6);
}
/**
* @param list<array<string, mixed>> $comparableRecords
*/
private static function isNoisyWindow(array $comparableRecords, float $varianceFloorSeconds): bool
{
if (count($comparableRecords) < 3) {
return false;
}
$directions = [];
for ($index = 0; $index < count($comparableRecords) - 1; $index++) {
$currentSeconds = (float) ($comparableRecords[$index]['wallClockSeconds'] ?? 0.0);
$previousSeconds = (float) ($comparableRecords[$index + 1]['wallClockSeconds'] ?? 0.0);
$deltaSeconds = round($currentSeconds - $previousSeconds, 6);
if (abs($deltaSeconds) <= $varianceFloorSeconds) {
continue;
}
$directions[] = $deltaSeconds > 0.0 ? 'up' : 'down';
}
return in_array('up', $directions, true) && in_array('down', $directions, true);
}
/**
* @return array<string, mixed>
*/
private static function loadTrendHistoryArtifact(string $laneId, ?string $artifactDirectory = null): array
{
$artifactPaths = self::artifactPaths($laneId, $artifactDirectory);
$trendHistoryPath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']);
if (! is_file($trendHistoryPath)) {
return [];
}
$decoded = json_decode((string) file_get_contents($trendHistoryPath), true);
return is_array($decoded) ? $decoded : [];
}
private static function resolveInputPath(string $path): string
{
if (str_starts_with($path, DIRECTORY_SEPARATOR)) {
return $path;
}
return self::repositoryRoot().DIRECTORY_SEPARATOR.ltrim($path, DIRECTORY_SEPARATOR);
}
private static function findTrendHistoryInDirectory(string $laneId, string $bundleDirectory): ?string
{
$candidates = [
sprintf('%s.trend-history.json', $laneId),
sprintf('%s-latest.trend-history.json', $laneId),
];
foreach ($candidates as $candidate) {
$candidatePath = rtrim($bundleDirectory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$candidate;
if (is_file($candidatePath)) {
return $candidatePath;
}
}
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($bundleDirectory));
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
if (! in_array($file->getFilename(), $candidates, true)) {
continue;
}
return $file->getPathname();
}
return null;
}
private static function findTrendHistoryInZip(string $laneId, ZipArchive $zip): ?string
{
$candidates = [
sprintf('%s.trend-history.json', $laneId),
sprintf('%s-latest.trend-history.json', $laneId),
];
for ($index = 0; $index < $zip->numFiles; $index++) {
$entryName = $zip->getNameIndex($index);
if (! is_string($entryName)) {
continue;
}
foreach ($candidates as $candidate) {
if (str_ends_with($entryName, $candidate)) {
return $entryName;
}
}
}
return null;
}
/**
* @param array{junit: string, summary: string, budget: string, report: string, profile: string, trendHistory: string} $artifactPaths
* @return array<string, string>
*/
private static function artifactFileMap(array $artifactPaths): array
@ -947,6 +1839,7 @@ private static function artifactFileMap(array $artifactPaths): array
'budget.json' => $artifactPaths['budget'],
'report.json' => $artifactPaths['report'],
'profile.txt' => $artifactPaths['profile'],
'trend-history.json' => $artifactPaths['trendHistory'],
];
}
@ -981,4 +1874,4 @@ private static function ensureDirectory(string $directory): void
mkdir($directory, 0777, true);
}
}
}

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class TestLaneTrendFixtures
{
public static function artifactDirectory(string $suffix): string
{
return sprintf('storage/logs/test-lanes/%s', trim($suffix, '/'));
}
/**
* @param array<string, float> $durationsByFile
* @return list<array<string, mixed>>
*/
public static function slowestEntries(array $durationsByFile, string $laneId): array
{
$entries = array_values(array_map(
static fn (float $seconds, string $filePath): array => [
'label' => $filePath.'::synthetic',
'subject' => $filePath.'::synthetic',
'filePath' => $filePath,
'durationSeconds' => $seconds,
'wallClockSeconds' => $seconds,
'laneId' => $laneId,
],
$durationsByFile,
array_keys($durationsByFile),
));
usort($entries, static fn (array $left, array $right): int => $right['wallClockSeconds'] <=> $left['wallClockSeconds']);
return $entries;
}
/**
* @param array<string, float> $durationsByFile
* @param array<string, mixed>|null $ciContext
* @return array<string, mixed>
*/
public static function buildReport(
string $laneId,
float $wallClockSeconds,
array $durationsByFile = [],
?string $artifactDirectory = null,
?array $ciContext = null,
?string $comparisonProfile = null,
): array {
return TestLaneReport::buildReport(
laneId: $laneId,
wallClockSeconds: $wallClockSeconds,
slowestEntries: self::slowestEntries($durationsByFile, $laneId),
durationsByFile: $durationsByFile,
artifactDirectory: $artifactDirectory,
comparisonProfile: $comparisonProfile,
ciContext: $ciContext,
);
}
/**
* @param array<string, mixed> $artifact
*/
public static function writeTrendHistory(string $laneId, array $artifact, ?string $artifactDirectory = null): string
{
$artifactPaths = TestLaneReport::artifactPaths($laneId, $artifactDirectory);
$absolutePath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']);
$directory = dirname($absolutePath);
if (! is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents($absolutePath, json_encode($artifact, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
return $absolutePath;
}
/**
* @return array<string, mixed>
*/
public static function readTrendHistory(string $laneId, ?string $artifactDirectory = null): array
{
$artifactPaths = TestLaneReport::artifactPaths($laneId, $artifactDirectory);
$absolutePath = TestLaneManifest::absolutePath($artifactPaths['trendHistory']);
if (! is_file($absolutePath)) {
return [];
}
$decoded = json_decode((string) file_get_contents($absolutePath), true);
return is_array($decoded) ? $decoded : [];
}
}

View File

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Support\OperateHub\OperateHubShell;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
it('prefers a valid tenant query hint over remembered tenant state on workspace-scoped admin routes', function (): void {
$rememberedTenant = Tenant::factory()->active()->create(['name' => 'Remembered Tenant']);
[$user, $rememberedTenant] = createUserWithTenant(tenant: $rememberedTenant, role: 'owner');
$hintedTenant = Tenant::factory()->active()->create([
'workspace_id' => (int) $rememberedTenant->workspace_id,
'name' => 'Hinted Tenant',
]);
createUserWithTenant(tenant: $hintedTenant, user: $user, role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $rememberedTenant->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $workspaceId => (int) $rememberedTenant->getKey(),
]);
$request = Request::create(route('admin.operations.index', ['tenant' => $hintedTenant->external_id]));
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->resolvedContext($request);
expect($resolved->workspace?->getKey())->toBe($workspaceId)
->and($resolved->tenant?->is($hintedTenant))->toBeTrue()
->and($resolved->tenantSource)->toBe('query_hint')
->and($resolved->state)->toBe('tenant_scoped');
});
it('falls back to a tenantless workspace state when a tenant query hint targets another workspace', function (): void {
$workspaceTenant = Tenant::factory()->active()->create(['name' => 'Current Workspace Tenant']);
[$user, $workspaceTenant] = createUserWithTenant(tenant: $workspaceTenant, role: 'owner');
$foreignTenant = Tenant::factory()->active()->create(['name' => 'Foreign Tenant']);
createUserWithTenant(tenant: $foreignTenant, user: User::factory()->create(), role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$workspaceId = (int) $workspaceTenant->workspace_id;
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
$request = Request::create(route('admin.operations.index', ['tenant' => $foreignTenant->external_id]));
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->resolvedContext($request);
expect($resolved->workspace?->getKey())->toBe($workspaceId)
->and($resolved->tenant)->toBeNull()
->and($resolved->state)->toBe('tenantless_workspace')
->and($resolved->recoveryReason)->toBe('mismatched_workspace');
});
it('uses the routed tenant workspace when the tenant panel is entered without a selected workspace session', function (): void {
$tenant = Tenant::factory()->active()->create(['name' => 'Tenant Panel Scope']);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
session()->forget(WorkspaceContext::SESSION_KEY);
$request = Request::create("/admin/t/{$tenant->external_id}");
$request->setLaravelSession(app('session.store'));
$request->setUserResolver(static fn () => $user);
$route = app('router')->getRoutes()->match($request);
$request->setRouteResolver(static fn () => $route);
$resolved = app(OperateHubShell::class)->resolvedContext($request);
expect($resolved->workspace?->getKey())->toBe((int) $tenant->workspace_id)
->and($resolved->tenant?->is($tenant))->toBeTrue()
->and($resolved->workspaceSource)->toBe('route')
->and($resolved->tenantSource)->toBe('route');
});

View File

@ -11,9 +11,12 @@
expect(TenantPageCategory::fromPath($path))->toBe($expected);
})->with([
'workspace overview' => ['/admin', TenantPageCategory::WorkspaceScoped],
'workspace chooser exception' => ['/admin/choose-workspace', TenantPageCategory::WorkspaceChooserException],
'tenant chooser' => ['/admin/choose-tenant', TenantPageCategory::WorkspaceScoped],
'tenant detail' => ['/admin/tenants/tenant-123', TenantPageCategory::TenantBound],
'tenant panel route' => ['/admin/t/tenant-123', TenantPageCategory::TenantBound],
'tenant scoped evidence detail' => ['/admin/evidence/123', TenantPageCategory::TenantScopedEvidence],
'evidence overview' => ['/admin/evidence/overview', TenantPageCategory::WorkspaceScoped],
'onboarding index' => ['/admin/onboarding', TenantPageCategory::OnboardingWorkflow],
'onboarding draft' => ['/admin/onboarding/42', TenantPageCategory::OnboardingWorkflow],
'operations index' => ['/admin/operations', TenantPageCategory::WorkspaceScoped],

View File

@ -635,10 +635,11 @@ ### Key Commands
```bash
# Local dev
corepack pnpm install # Install workspace JS dependencies
corepack pnpm dev:platform # Start the platform stack
corepack pnpm dev:platform # Start the platform stack + panel Vite watcher
corepack pnpm dev:website # Start the website
corepack pnpm dev # Start platform Vite + website together
corepack pnpm build:website # Build the website
cd apps/platform && ./vendor/bin/sail pnpm build # Build platform frontend
corepack pnpm build:platform # Build platform frontend inside Sail
# Testing
cd apps/platform && ./vendor/bin/sail artisan test --compact # Full suite

View File

@ -0,0 +1,237 @@
# Website Working Contract
> Guardrails for evolving `apps/website` as an independently evolvable track in the current repository.
> This document is repo-truth-based and describes the currently verified state, not a speculative future architecture.
**Last reviewed**: 2026-04-18
---
## 1. Purpose
`apps/website` is currently treated as an independently evolvable website track.
This contract is based on the current repository state:
- no verified runtime coupling to `apps/platform`
- no verified shared DTO, enum, API, or auth contracts
- current relevant coupling is primarily through root scripts, the pnpm workspace, the website package name, `WEBSITE_PORT`, and `apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php`
Therefore, website work should be planned and implemented as technically independent by default unless a known minimal contract is being changed.
## 2. Scope
This working contract applies to:
- `apps/website/**`
- root files only insofar as they directly define or preserve website-facing workspace contracts
This document does not grant implicit approval to introduce new coupling to:
- `apps/platform`
- shared runtime APIs
- shared auth or session behavior
- shared domain models
- shared packages
Such coupling must be introduced deliberately and treated as a new explicit contract.
## 3. Current Repo-Based Statement
At the current verified repo state, `apps/website` is:
- a small static Astro application
- built from local layout, local page, and local CSS files
- without verified platform runtime dependency
- without verified shared workspace packages
- without verified platform API usage
- without verified auth or session sharing
The current real technical coupling is concentrated in:
- `package.json`
- `pnpm-workspace.yaml`
- `apps/website/package.json`
- `README.md`
- `apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php`
## 4. Freely Changeable Without Special Coordination
The following changes are currently treated as website-internal and are generally free to make as long as they do not create new outward-facing contracts.
### Content and Structure
- copy
- headlines
- messaging
- CTA text
- page sections
- HTML structure inside the website
### UI and Frontend
- styling in `src/styles/global.css`
- layout changes in `src/layouts/BaseLayout.astro`
- additional components inside `apps/website`
- additional pages under `src/pages`
- additional layouts
- additional local assets
### Astro-Local Evolution
- expanding page structure
- refactoring inline markup into components
- app-local reorganization inside `apps/website`
- local SEO, meta, robots, and favicon updates
### Usually Non-Critical When Kept Local
- new website-only dependencies
- app-local build or styling improvements
- app-local content structures
## 5. Changes Requiring Coordination
The following changes must not happen silently because they touch real existing contracts in the current repository.
### Hard Minimal Contracts
- changing the package name `@tenantatlas/website`
- changing the root script names for website flows
- changing the `WEBSITE_PORT` convention
- changing workspace membership via `apps/*`
- moving `apps/website` out of the current workspace pattern
### Repo and Tooling Contracts
- changes that break `pnpm --filter @tenantatlas/website ...`
- changes that break the root `dev:website` or `build:website` workflows
- changes that break `PlatformWorkspaceCompatibilityTest.php`
- changes to root scripts when the website is expected to remain reachable through root commands
### New Technical Couplings
- introducing API calls to `apps/platform`
- introducing shared DTOs, enums, or payload contracts
- introducing shared auth or session mechanics
- introducing shared packages as a required dependency
- introducing shared content or asset sources
- introducing a shared runtime deployment model
## 6. Silent Assumptions That Are Not Allowed
For website work, the following assumptions are explicitly disallowed:
- monorepo membership does not automatically mean technical coupling
- `apps/platform` is not automatically part of website frontend scope
- Filament or Livewire changes are not automatically website-relevant
- shared product domain does not automatically imply a shared technical contract
- future integrations must not be pre-assumed when they do not exist in the repo
## 7. Rule for New Couplings
New coupling between website and platform is allowed only when it is introduced as an explicit new contract.
Before implementation, the following must be made clear:
- which coupling is being introduced
- where it is defined in the repo
- whether it is runtime, build, deploy, or convention coupling
- what impact it has on independent evolution of `apps/website`
- what stability guarantee is expected going forward
Without that explicit decision, the default rule is:
**Do not introduce a new coupling.**
## 8. Review Rule for Website PRs
A change in `apps/website` is uncritical by default when all of the following can be answered with `Yes`:
- Does the package name `@tenantatlas/website` remain unchanged?
- Do the root scripts remain functionally compatible?
- Does `WEBSITE_PORT` remain unchanged?
- Does workspace membership through `apps/*` remain intact?
- Is no new dependency on `apps/platform` introduced?
- Is no new API, auth, DTO, or shared-package contract introduced?
- Does the change stay entirely inside `apps/website`?
If any answer is `No`, the change is coordination-required.
## 9. Change Classes
### Class A — Free
Pure website change with no new outward-facing contract.
Examples:
- new landing page
- refactor of `index.astro`
- new components
- CSS restructuring
- local SEO changes
### Class B — Cautious
Change likely remains local but touches website build or dev behavior.
Examples:
- adjusting Astro configuration
- adding website dependencies
- larger structural changes in `src/`
Normal website review is usually sufficient.
### Class C — Coordination-Required
Change touches root, workspace, or test contracts, or introduces new coupling.
Examples:
- renaming the package
- changing scripts
- changing the port convention
- changing the workspace pattern
- integrating a platform API
- introducing shared types
These changes require explicit coordination.
## 10. Operational Summary
Yes: `apps/website` may currently be developed as its own frontend and website track.
Condition: the known minimal contracts stay stable:
- `@tenantatlas/website`
- root scripts
- `WEBSITE_PORT`
- `apps/*`
- compatibility with `PlatformWorkspaceCompatibilityTest.php`
Not automatically part of website scope:
- changes in `apps/platform`
Introduce only deliberately:
- any new runtime, API, auth, shared-package, or DTO coupling
## 11. Evidence Anchors
This contract is grounded in the current repo state represented by these files:
- `apps/website/package.json`
- `apps/website/astro.config.mjs`
- `apps/website/src/pages/index.astro`
- `apps/website/src/layouts/BaseLayout.astro`
- `apps/website/src/styles/global.css`
- `package.json`
- `pnpm-workspace.yaml`
- `README.md`
- `apps/platform/tests/Feature/WorkspaceFoundation/PlatformWorkspaceCompatibilityTest.php`
- `docker-compose.yml`
If these files change materially, this contract should be revalidated against the current checkout.

View File

@ -6,10 +6,10 @@
"node": ">=20.0.0"
},
"scripts": {
"dev": "./scripts/platform-sail up -d && corepack pnpm dev:website",
"dev:platform": "./scripts/platform-sail up -d",
"dev": "bash ./scripts/dev-workspace",
"dev:platform": "bash ./scripts/dev-platform",
"dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev",
"build:platform": "corepack pnpm --filter @tenantatlas/platform build",
"build:platform": "./scripts/platform-sail pnpm build",
"build:website": "corepack pnpm --filter @tenantatlas/website build"
}
}

15
scripts/dev-platform Normal file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/platform-sail" up -d
if curl --silent --fail --max-time 2 http://127.0.0.1:5173/@vite/client >/dev/null; then
echo "Platform Vite dev server already running at http://localhost:5173"
exit 0
fi
exec bash "${SCRIPT_DIR}/platform-vite-dev"

28
scripts/dev-workspace Normal file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
platform_pid=''
cleanup() {
if [[ -n "${platform_pid}" ]]; then
kill "${platform_pid}" 2>/dev/null || true
wait "${platform_pid}" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
"${SCRIPT_DIR}/platform-sail" up -d
if curl --silent --fail --max-time 2 http://127.0.0.1:5173/@vite/client >/dev/null; then
echo "Platform Vite dev server already running at http://localhost:5173"
else
bash "${SCRIPT_DIR}/platform-vite-dev" &
platform_pid=$!
fi
cd "${ROOT_DIR}"
WEBSITE_PORT="${WEBSITE_PORT:-4321}" corepack pnpm --filter @tenantatlas/website dev

View File

@ -11,4 +11,6 @@ APP_DIR="${SCRIPT_DIR}/../apps/platform"
cd "${APP_DIR}"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-tenantatlas}"
exec ./vendor/bin/sail "$@"

View File

@ -9,12 +9,15 @@ LANE="${1:-fast-feedback}"
CAPTURE_BASELINE=false
WORKFLOW_ID=""
TRIGGER_CLASS=""
HISTORY_FILE=""
HISTORY_BUNDLE=""
FETCH_LATEST_HISTORY=auto
copy_heavy_baseline_artifacts() {
local artifact_root="${APP_DIR}/storage/logs/test-lanes"
local suffix
for suffix in summary.md budget.json report.json; do
for suffix in summary.md budget.json report.json trend-history.json; do
local latest_path="${artifact_root}/heavy-governance-latest.${suffix}"
local baseline_path="${artifact_root}/heavy-governance-baseline.${suffix}"
@ -40,6 +43,9 @@ case "${LANE}" in
profiling|profile)
COMPOSER_SCRIPT="test:report:profile"
;;
junit)
COMPOSER_SCRIPT="test:report:junit"
;;
*)
echo "Unknown test lane: ${LANE}" >&2
exit 1
@ -64,9 +70,29 @@ for arg in "$@"; do
continue
fi
if [[ "${arg}" == --history-file=* ]]; then
HISTORY_FILE="${arg#--history-file=}"
continue
fi
if [[ "${arg}" == --history-bundle=* ]]; then
HISTORY_BUNDLE="${arg#--history-bundle=}"
continue
fi
if [[ "${arg}" == "--fetch-latest-history" ]]; then
FETCH_LATEST_HISTORY=true
continue
fi
if [[ "${arg}" == "--skip-latest-history" ]]; then
FETCH_LATEST_HISTORY=false
continue
fi
echo "Unknown option: ${arg}" >&2
exit 1
done
done
if [[ "${CAPTURE_BASELINE}" == true && "${LANE}" != "heavy-governance" && "${LANE}" != "heavy" ]]; then
echo "--capture-baseline is only supported for heavy-governance" >&2
@ -81,8 +107,251 @@ if [[ -n "${TRIGGER_CLASS}" ]]; then
export TENANTATLAS_CI_TRIGGER_CLASS="${TRIGGER_CLASS}"
fi
trend_history_target_path() {
echo "${APP_DIR}/storage/logs/test-lanes/${LANE}-latest.trend-history.json"
}
resolve_input_path() {
local path="${1:-}"
if [[ -z "${path}" ]]; then
return 1
fi
if [[ "${path}" = /* ]]; then
echo "${path}"
return 0
fi
echo "${ROOT_DIR}/${path#./}"
}
hydrate_trend_history_from_file() {
local source_path="${1:-}"
local target_path
target_path="$(trend_history_target_path)"
if [[ -z "${source_path}" || ! -f "${source_path}" ]]; then
return 1
fi
mkdir -p "$(dirname "${target_path}")"
cp "${source_path}" "${target_path}"
return 0
}
hydrate_trend_history_from_bundle() {
local bundle_path="${1:-}"
local target_path
local candidate
target_path="$(trend_history_target_path)"
if [[ -z "${bundle_path}" ]]; then
return 1
fi
if [[ -d "${bundle_path}" ]]; then
for candidate in \
"${bundle_path}/${LANE}.trend-history.json" \
"${bundle_path}/${LANE}-latest.trend-history.json"
do
if [[ -f "${candidate}" ]]; then
mkdir -p "$(dirname "${target_path}")"
cp "${candidate}" "${target_path}"
return 0
fi
done
candidate="$(find "${bundle_path}" -type f \( -name "${LANE}.trend-history.json" -o -name "${LANE}-latest.trend-history.json" \) | head -n 1)"
if [[ -n "${candidate}" && -f "${candidate}" ]]; then
mkdir -p "$(dirname "${target_path}")"
cp "${candidate}" "${target_path}"
return 0
fi
fi
if [[ -f "${bundle_path}" && "${bundle_path,,}" == *.zip ]]; then
candidate="$(python3 - "${bundle_path}" "${LANE}" <<'PY'
import sys
import zipfile
bundle_path, lane = sys.argv[1], sys.argv[2]
candidates = [f"{lane}.trend-history.json", f"{lane}-latest.trend-history.json"]
with zipfile.ZipFile(bundle_path) as archive:
for name in archive.namelist():
if any(name.endswith(candidate) for candidate in candidates):
print(name)
break
PY
)"
if [[ -n "${candidate}" ]]; then
mkdir -p "$(dirname "${target_path}")"
unzip -p "${bundle_path}" "${candidate}" > "${target_path}"
return 0
fi
fi
return 1
}
parse_remote_origin() {
local origin_url
origin_url="$(git -C "${ROOT_DIR}" config --get remote.origin.url 2>/dev/null || true)"
if [[ -z "${origin_url}" ]]; then
return 1
fi
python3 - "${origin_url}" <<'PY'
import re
import sys
origin = sys.argv[1].strip()
patterns = [
re.compile(r'^(https?://[^/]+)/([^/]+)/([^/]+?)(?:\.git)?$'),
re.compile(r'^git@([^:]+):([^/]+)/([^/]+?)(?:\.git)?$'),
re.compile(r'^ssh://git@([^/]+)/([^/]+)/([^/]+?)(?:\.git)?$'),
]
for pattern in patterns:
match = pattern.match(origin)
if not match:
continue
groups = match.groups()
if origin.startswith("http://") or origin.startswith("https://"):
host, owner, repo = groups
else:
host, owner, repo = groups
host = f"https://{host}"
print(host)
print(owner)
print(repo)
sys.exit(0)
sys.exit(1)
PY
}
download_latest_history_bundle() {
local token
local artifact_name
local remote_parts
local host
local owner
local repo
local listing_path
local artifact_id
local bundle_dir
local bundle_path
token="${TENANTATLAS_GITEA_TOKEN:-${GITEA_TOKEN:-}}"
if [[ -z "${token}" ]]; then
return 1
fi
mapfile -t remote_parts < <(parse_remote_origin) || return 1
if [[ "${#remote_parts[@]}" -ne 3 ]]; then
return 1
fi
host="${remote_parts[0]}"
owner="${remote_parts[1]}"
repo="${remote_parts[2]}"
artifact_name="${LANE}-artifacts"
listing_path="${ROOT_DIR}/.gitea-artifacts/_history/${LANE}-artifacts.json"
mkdir -p "${ROOT_DIR}/.gitea-artifacts/_history"
curl --fail --silent --show-error \
-H "Authorization: token ${token}" \
-H "Accept: application/json" \
"${host}/api/v1/repos/${owner}/${repo}/actions/artifacts?name=${artifact_name}" \
-o "${listing_path}" || return 1
artifact_id="$(php -r '
$data = json_decode((string) file_get_contents($argv[1]), true);
$currentRunId = getenv("GITEA_RUN_ID") ?: getenv("GITHUB_RUN_ID") ?: "";
foreach (($data["artifacts"] ?? []) as $artifact) {
if (($artifact["expired"] ?? false) === true) {
continue;
}
$artifactRunId = (string) ($artifact["workflow_run"]["id"] ?? "");
if ($currentRunId !== "" && $artifactRunId === (string) $currentRunId) {
continue;
}
echo (string) ($artifact["id"] ?? "");
break;
}
' "${listing_path}")"
if [[ -z "${artifact_id}" ]]; then
return 1
fi
bundle_dir="${ROOT_DIR}/.gitea-artifacts/_history/${LANE}"
bundle_path="${bundle_dir}/artifact.zip"
rm -rf "${bundle_dir}"
mkdir -p "${bundle_dir}"
curl --fail --silent --show-error --location \
-H "Authorization: token ${token}" \
"${host}/api/v1/repos/${owner}/${repo}/actions/artifacts/${artifact_id}/zip" \
-o "${bundle_path}" || return 1
echo "${bundle_path}"
return 0
}
hydrate_trend_history() {
local resolved_history_file=""
local resolved_history_bundle=""
local downloaded_bundle=""
if [[ -n "${HISTORY_FILE}" ]]; then
resolved_history_file="$(resolve_input_path "${HISTORY_FILE}")"
fi
if [[ -n "${HISTORY_BUNDLE}" ]]; then
resolved_history_bundle="$(resolve_input_path "${HISTORY_BUNDLE}")"
fi
if [[ -n "${resolved_history_file}" ]] && hydrate_trend_history_from_file "${resolved_history_file}"; then
return 0
fi
if [[ -n "${resolved_history_bundle}" ]] && hydrate_trend_history_from_bundle "${resolved_history_bundle}"; then
return 0
fi
if [[ "${FETCH_LATEST_HISTORY}" == true || ( "${FETCH_LATEST_HISTORY}" == auto && -n "${WORKFLOW_ID}" ) ]]; then
downloaded_bundle="$(download_latest_history_bundle || true)"
if [[ -n "${downloaded_bundle}" ]] && hydrate_trend_history_from_bundle "${downloaded_bundle}"; then
return 0
fi
fi
return 0
}
cd "${APP_DIR}"
hydrate_trend_history
./vendor/bin/sail composer run --timeout=0 "${COMPOSER_SCRIPT}"
if [[ "${CAPTURE_BASELINE}" == true ]]; then

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/platform-sail" pnpm dev

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Global Context Shell Contract
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Specification reviewed against the repo spec template and constitution prompts on 2026-04-18.
- No `[NEEDS CLARIFICATION]` markers remain.
- Route and shell-surface references are included only to bound the affected product surfaces and do not prescribe implementation structure.

View File

@ -0,0 +1,467 @@
openapi: 3.1.0
info:
title: Global Context Shell Logical Contract
version: 0.1.0
summary: Logical HTTP contract for workspace and tenant shell context resolution and mutation flows
description: >-
This is a logical contract for Spec 199. The real routes render HTML and redirects,
but the schemas below define the canonical request-scoped shell context and the
expected redirect or recovery outcomes for shared workspace and tenant shell flows.
servers:
- url: /
description: Application root for admin and tenant shell entry surfaces
tags:
- name: shell-context
- name: workspace-switch
- name: tenant-select
- name: tenant-clear
paths:
/admin:
get:
tags: [shell-context]
summary: Resolve workspace-scoped shell entry
description: Resolve the active workspace and optional tenant context for a workspace-scoped admin route, including query-backed tenant hints only where the contract explicitly allows them.
parameters:
- name: tenant
in: query
required: false
schema:
type: string
description: Optional tenant external-ID hint on routes that explicitly allow query-backed shell resolution.
- name: tenant_id
in: query
required: false
schema:
oneOf:
- type: integer
- type: string
description: Optional tenant identifier hint on workspace-scoped routes that explicitly allow query-backed context hints.
responses:
'200':
description: Workspace-scoped shell entry resolved successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
tenantless:
value:
resolvedContext:
state: tenantless_workspace
displayMode: tenantless
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: none
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant: null
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
rememberedTenant:
value:
resolvedContext:
state: tenant_scoped
displayMode: tenant_scoped
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: remembered
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant:
id: 7
externalId: tenant-7
name: Tenant Seven
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
queryHintTenant:
value:
resolvedContext:
state: tenant_scoped
displayMode: tenant_scoped
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: query_hint
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant:
id: 7
externalId: tenant-7
name: Tenant Seven
requestedContext:
workspaceIdentifier: null
tenantIdentifier: tenant-7
source: query_hint
pageCategory: workspace_scoped
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
'302':
description: No valid workspace could be resolved and the user must be redirected to a chooser or safe fallback.
'404':
description: The requested context implies inaccessible or invalid workspace-bound data that cannot be widened safely.
/admin/choose-workspace:
get:
tags: [shell-context]
summary: Resolve the explicit workspace chooser exception route
description: Render the explicit workspace chooser exception route used when no workspace truth can be recovered or when the operator must select a workspace directly.
responses:
'200':
description: Workspace chooser rendered as the explicit recovery route.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
chooser:
value:
resolvedContext:
state: missing_workspace
displayMode: recovery
pageCategory: workspace_chooser_exception
workspaceSource: none
tenantSource: none
workspace: null
tenant: null
recoveryDirective:
action: none
reason: missing_workspace
destination: /admin/choose-workspace
preserveIntendedUrl: true
/admin/choose-tenant:
get:
tags: [shell-context]
summary: Resolve the explicit choose-tenant route after workspace selection
description: Render the explicit choose-tenant route used when a resolved workspace has multiple selectable tenants.
responses:
'200':
description: Choose-tenant route rendered successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
chooseTenant:
value:
resolvedContext:
state: tenantless_workspace
displayMode: tenantless
pageCategory: workspace_scoped
workspaceSource: session_workspace
tenantSource: none
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant: null
recoveryDirective:
action: none
reason: null
destination: /admin/choose-tenant
preserveIntendedUrl: false
/admin/t/{external_id}:
get:
tags: [shell-context]
summary: Resolve tenant-bound shell entry
description: Resolve tenant context for a tenant-bound route where explicit tenant routing is required.
parameters:
- name: external_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Tenant-bound shell entry resolved successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/ResolvedShellContextEnvelope'
examples:
tenantBound:
value:
resolvedContext:
state: tenant_scoped
displayMode: tenant_scoped
pageCategory: tenant_bound
workspaceSource: session_workspace
tenantSource: route
workspace:
id: 42
slug: alpha-workspace
name: Alpha Workspace
tenant:
id: 7
externalId: tenant-7
name: Tenant Seven
recoveryDirective:
action: none
reason: null
destination: null
preserveIntendedUrl: false
'404':
description: The route tenant is invalid, inaccessible, or incompatible with the active workspace.
/admin/switch-workspace:
post:
tags: [workspace-switch]
summary: Switch the active workspace
description: Set the active workspace, re-evaluate tenant compatibility, and redirect to a safe concrete destination such as an intended `/admin...` URL, `admin.workspace.managed-tenants.index`, `/admin/choose-tenant`, or the tenant dashboard.
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [workspace_id]
properties:
workspace_id:
type: integer
responses:
'302':
description: Workspace switch accepted and redirected to intended URL or resolver destination.
headers:
Location:
schema:
type: string
description: Safe destination for the resolved workspace and resulting tenant state, currently an intended `/admin...` URL, `admin.workspace.managed-tenants.index`, `/admin/choose-tenant`, or a tenant dashboard route.
'404':
description: Workspace does not exist, is archived, or is not accessible to the current user.
'422':
description: Request body failed validation.
/admin/select-tenant:
post:
tags: [tenant-select]
summary: Select the active tenant inside the resolved workspace
description: Explicitly activate a tenant that belongs to the current workspace and passes entitlement and operability checks, then redirect to the deterministic tenant entry route for that tenant.
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
required: [tenant_id]
properties:
tenant_id:
type: integer
responses:
'302':
description: Tenant selection accepted and redirected to the deterministic tenant entry route for the selected tenant.
headers:
Location:
schema:
type: string
'404':
description: Tenant is missing, inaccessible, incompatible with the active workspace, or fails operability rules.
'422':
description: Request body failed validation.
/admin/clear-tenant-context:
post:
tags: [tenant-clear]
summary: Clear active tenant context
description: Remove remembered and panel tenant state, then resolve according to page category and route compatibility to either same-route tenantless workspace state or one of the documented concrete destinations: `admin.operations.index`, `admin.evidence.overview`, `admin.workspace.managed-tenants.index`, `admin.operations.view`, or `admin.home`.
responses:
'302':
description: Tenant context cleared and request resolved to tenantless workspace state on the current route or redirected to one of the documented concrete workspace-safe fallbacks.
headers:
Location:
schema:
type: string
'404':
description: The current route cannot recover safely because scope is no longer accessible.
components:
schemas:
RequestedContext:
type: object
properties:
workspaceIdentifier:
oneOf:
- type: integer
- type: string
- type: 'null'
tenantIdentifier:
oneOf:
- type: integer
- type: string
- type: 'null'
source:
$ref: '#/components/schemas/ContextSource'
pageCategory:
$ref: '#/components/schemas/PageCategory'
RememberedContextCandidate:
type: object
properties:
workspaceId:
type: integer
tenantId:
type:
- integer
- 'null'
source:
$ref: '#/components/schemas/ContextSource'
eligible:
type: boolean
invalidReason:
type:
- string
- 'null'
ResolvedShellContextEnvelope:
type: object
required: [resolvedContext]
properties:
resolvedContext:
$ref: '#/components/schemas/ResolvedShellContext'
ResolvedShellContext:
type: object
required:
- state
- displayMode
- pageCategory
- workspaceSource
- tenantSource
- workspace
- tenant
- recoveryDirective
properties:
state:
$ref: '#/components/schemas/ShellState'
displayMode:
type: string
enum:
- tenant_scoped
- tenantless
- recovery
pageCategory:
$ref: '#/components/schemas/PageCategory'
workspaceSource:
$ref: '#/components/schemas/ContextSource'
tenantSource:
$ref: '#/components/schemas/ContextSource'
workspace:
oneOf:
- $ref: '#/components/schemas/WorkspaceReference'
- type: 'null'
tenant:
oneOf:
- $ref: '#/components/schemas/TenantReference'
- type: 'null'
requestedContext:
oneOf:
- $ref: '#/components/schemas/RequestedContext'
- type: 'null'
rememberedContext:
oneOf:
- $ref: '#/components/schemas/RememberedContextCandidate'
- type: 'null'
recoveryDirective:
$ref: '#/components/schemas/RecoveryDirective'
RecoveryDirective:
type: object
required: [action, reason, destination, preserveIntendedUrl]
properties:
action:
$ref: '#/components/schemas/RecoveryAction'
reason:
type:
- string
- 'null'
destination:
type:
- string
- 'null'
preserveIntendedUrl:
type: boolean
WorkspaceReference:
type: object
required: [id, slug, name]
properties:
id:
type: integer
slug:
type: string
name:
type: string
TenantReference:
type: object
required: [id, externalId, name]
properties:
id:
type: integer
externalId:
type: string
name:
type: string
ContextSource:
type: string
enum:
- route
- explicit_switch
- explicit_select
- session_workspace
- filament_tenant
- remembered
- query_hint
- none
PageCategory:
type: string
enum:
- workspace_scoped
- workspace_chooser_exception
- tenant_bound
- tenant_scoped_evidence
- canonical_workspace_record_viewer
ShellState:
type: string
enum:
- tenant_scoped
- tenantless_workspace
- missing_workspace
- invalid_workspace
- missing_tenant
- invalid_tenant
- inaccessible_tenant
- incompatible_tenant
RecoveryAction:
type: string
enum:
- none
- render_tenantless_workspace
- redirect_choose_workspace
- redirect_operations_index
- redirect_evidence_overview
- redirect_workspace_home
- redirect_workspace_managed_tenants
- redirect_workspace_record_fallback
- abort_not_found

View File

@ -0,0 +1,227 @@
# Data Model: Global Context Shell Contract
## Persistence Impact
- No new database tables, columns, or persisted shell artifacts are introduced.
- Existing session-backed fields remain the only durable support state used by the contract:
- `current_workspace_id`
- `workspace_intended_url`
- `workspace_last_tenant_ids`
- Existing user-level fields such as `users.last_workspace_id` and `users.last_tenant_id` or `user_tenant_preferences.last_used_at` remain support inputs only and do not become active shell truth.
## Context Source Inventory
| Source | Context facet | Source role | Owning seam | Validation / notes |
|---|---|---|---|---|
| Explicit workspace switch request | Workspace | leading | `SwitchWorkspaceController` + `WorkspaceContext` | Must point to an accessible, selectable workspace before it can replace current workspace truth. |
| Current session workspace | Workspace | leading | `WorkspaceContext` | Remains the default current-workspace truth when no stronger explicit request exists and membership is still valid. |
| Remembered last workspace | Workspace | supporting | `WorkspaceContext` | Restore-only candidate used when no valid current session workspace exists and the entry flow allows restore. |
| Route tenant parameter | Tenant | leading | Route + `OperateHubShell` | Strongest tenant source on tenant-bound routes. Must belong to the resolved workspace and remain entitled. |
| Explicit tenant selection request | Tenant | leading | `SelectTenantController` + `OperateHubShell` | Can activate tenant scope only inside an already resolved workspace. |
| Filament panel tenant state | Tenant | supporting | `ResolvesPanelTenantContext` | May support resolution only after workspace compatibility and entitlement checks succeed. |
| Remembered tenant for resolved workspace | Tenant | supporting | `WorkspaceContext` | Restore-only candidate on tenantless-capable workspace pages. Never valid on its own for tenant-bound routes. |
| Query hint | Workspace or Tenant | supporting only when contract explicitly allows it | `OperateHubShell` | Must never become effective truth unless the contract explicitly names the query-backed flow. |
| View-local shell inference | Workspace or Tenant | never-leading | Shared shell partials and page-local views | Rendering surfaces may display resolved truth only. They cannot evaluate precedence or recovery. |
## Runtime Entities
| Entity | Kind | Fields | Validation / Notes |
|---|---|---|---|
| RequestedWorkspaceContext | Derived runtime input | `workspaceIdentifier`, `source`, `intendedUrl`, `pageCategory` | Represents a workspace request from route, explicit switch flow, or initial restore path before validation. |
| RequestedTenantContext | Derived runtime input | `tenantIdentifier`, `source`, `pageCategory`, `requiresExplicitTenant` | Represents route tenant, explicit tenant select, query hint, Filament tenant, or remembered tenant before validation. |
| RememberedContextCandidate | Derived support state | `workspaceId`, `tenantId`, `source`, `eligible` | Represents stored last-used tenant for the active workspace or last-used workspace during initial resolution. Never leading by itself. |
| ResolvedShellContext | Canonical request-scoped truth | `workspace`, `tenant`, `pageCategory`, `workspaceSource`, `tenantSource`, `state`, `recoveryDirective`, `displayMode` | The only context object shell UI and server-side consumers should trust for the current request. |
| RecoveryDirective | Derived outcome | `action`, `destination`, `reason`, `preserveIntendedUrl` | Encodes whether the request renders tenantless state, redirects, or aborts. |
| InvalidContext | Derived runtime outcome | `kind`, `source`, `reason`, `requestedWorkspaceIdentifier`, `requestedTenantIdentifier` | Captures why a requested or remembered context could not become active truth. |
## Supporting Enums / Value Domains
### ContextSource
- `route`
- `explicit_switch`
- `explicit_select`
- `session_workspace`
- `filament_tenant`
- `remembered`
- `query_hint`
- `none`
### ShellState
- `tenant_scoped`
- `tenantless_workspace`
- `missing_workspace`
- `invalid_workspace`
- `missing_tenant`
- `invalid_tenant`
- `inaccessible_tenant`
- `incompatible_tenant`
### RecoveryAction
- `none`
- `render_tenantless_workspace`
- `redirect_choose_workspace`
- `redirect_operations_index`
- `redirect_evidence_overview`
- `redirect_workspace_home`
- `redirect_workspace_managed_tenants`
- `redirect_workspace_record_fallback`
- `abort_not_found`
### PageCategory
- `workspace_scoped`
- `workspace_chooser_exception`
- `tenant_bound`
- `tenant_scoped_evidence`
- `canonical_workspace_record_viewer`
## Entity Details
### RequestedWorkspaceContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `workspaceIdentifier` | string or int | yes | May come from explicit workspace switch flow or a safe intended URL restore path. |
| `source` | `ContextSource` | yes | `explicit_switch`, `session_workspace`, or `remembered` are the main inputs today. |
| `intendedUrl` | string or null | no | Safe `/admin...` path captured via `WorkspaceIntendedUrl`. |
| `pageCategory` | `PageCategory` | yes | Needed to determine if a tenantless fallback is valid after workspace resolution. |
**Validation rules**:
- Workspace must exist.
- Workspace must not be archived or otherwise unselectable.
- User must be a member of the workspace.
- Cross-plane routes remain out of scope; only `web`-guarded admin and tenant routes participate.
### RequestedTenantContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `tenantIdentifier` | string or int | yes | May come from route param, explicit tenant selection, query hint, Filament panel state, or remembered session state. |
| `source` | `ContextSource` | yes | Route and explicit selection are strongest; remembered is weakest. |
| `pageCategory` | `PageCategory` | yes | Determines whether tenant fallback is valid, optional, or forbidden. |
| `requiresExplicitTenant` | bool | yes | `true` for tenant-bound pages; `false` for workspace-scoped pages that can remain tenantless. |
**Validation rules**:
- Tenant must exist.
- Tenant must belong to the resolved workspace.
- User must be entitled to the tenant.
- Tenant must satisfy the relevant operability question for the current lane.
- Tenant must be compatible with the current route type.
### RememberedContextCandidate
| Field | Type | Required | Notes |
|---|---|---|---|
| `workspaceId` | int | yes | Key for the remembered tenant map. |
| `tenantId` | int or null | no | Candidate tenant for restore. |
| `source` | `ContextSource` | yes | Today this is `remembered`, with supporting user-level last-used values. |
| `eligible` | bool | yes | `false` once access, operability, or workspace match fails. |
| `invalidReason` | string or null | no | Captures why the remembered candidate became ineligible during validation or cleanup. |
**Validation rules**:
- Candidate tenant must still exist.
- Candidate tenant must still belong to the active workspace.
- Candidate tenant must still be accessible to the user.
- Candidate tenant must still pass `RememberedContextValidity` for the current lane.
- Ineligible remembered context is cleared immediately and cannot survive as visible shell truth.
### ResolvedShellContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `workspace` | Workspace or null | yes | Null only when recovery requires chooser or not-found handling. |
| `tenant` | Tenant or null | yes | Null is valid only in tenantless workspace state or before a hard recovery redirect. |
| `pageCategory` | `PageCategory` | yes | Controls whether tenantless state is valid. |
| `workspaceSource` | `ContextSource` | yes | Records which source actually won for workspace resolution. |
| `tenantSource` | `ContextSource` | yes | Records which source actually won for tenant resolution, or `none`. |
| `state` | `ShellState` | yes | The user-visible shell state. |
| `recoveryDirective` | `RecoveryDirective` | yes | Defines what to render or where to redirect if the request cannot continue as requested. |
| `displayMode` | string | yes | `tenant_scoped`, `tenantless`, or `recovery`. |
**Invariants**:
- A resolved tenant cannot exist without a resolved workspace.
- Remembered context cannot become active if a stronger valid source exists.
- The shell display must derive only from `ResolvedShellContext`.
- Tenant-bound pages cannot render a remembered-tenant fallback as though it were an explicit route tenant.
### InvalidContext
| Field | Type | Required | Notes |
|---|---|---|---|
| `kind` | string | yes | `workspace` or `tenant` |
| `source` | `ContextSource` | yes | Identifies whether route, panel, remembered, or query input failed. |
| `reason` | string | yes | `missing`, `inaccessible`, `incompatible`, `not_operable`, `not_member`, `archived`, or `mismatched_workspace` |
| `requestedWorkspaceIdentifier` | string or int or null | no | Included for diagnostics and testing only. |
| `requestedTenantIdentifier` | string or int or null | no | Included for diagnostics and testing only. |
## Relationships
- `ResolvedShellContext` is composed from zero or one `RequestedWorkspaceContext`, zero or one `RequestedTenantContext`, zero or one `RememberedContextCandidate`, and zero or one `InvalidContext` plus `RecoveryDirective`.
- `RecoveryDirective` is downstream of `ResolvedShellContext.state` and `PageCategory`.
- `RememberedContextCandidate.workspaceId` is always keyed to the resolved workspace candidate; it is not global across workspaces.
## Resolution Rules
### Workspace Resolution
1. Try a valid explicit workspace request when the current entry flow provides one.
2. Otherwise use the current session workspace if it remains valid.
3. Otherwise allow a valid last-used workspace restore only during initial resolution.
4. Otherwise emit `missing_workspace` or `invalid_workspace` with a chooser-oriented recovery directive.
### Tenant Resolution
1. On tenant-bound pages, validate route tenant first and fail if it is missing, inaccessible, or incompatible.
2. On workspace-scoped pages, accept a valid route tenant or explicit tenant-selection request first.
3. Next accept a validated query-backed tenant hint only on routes where the contract explicitly allows query-backed shell resolution.
4. Next accept validated `Filament::getTenant()` only if it matches the resolved workspace and remains entitled.
5. Next accept remembered tenant only when the page category permits tenantless fallback and no stronger valid tenant source exists.
6. Otherwise resolve to tenantless workspace state or to an explicit recovery directive depending on page category.
## Recovery Matrix
| Page category | Invalid workspace | Invalid explicit tenant | Missing tenant after clear | Invalid remembered tenant |
|---|---|---|---|---|
| `workspace_scoped` | redirect to chooser, or to `admin.operations.index` when cleanup is referrer-free or sentinel-driven | render tenantless workspace or clear request | render tenantless workspace on the current route, or use `admin.operations.index` when no safe prior route exists | clear remembered tenant and remain tenantless |
| `workspace_chooser_exception` | remain on `/admin/choose-workspace` | not applicable | remain on `/admin/choose-workspace` until the user selects a workspace | not applicable |
| `tenant_bound` | 404 or redirect to chooser if no workspace can be re-established | 404 when route tenant is invalid or inaccessible | redirect to `admin.workspace.managed-tenants.index` for the current workspace, else `admin.home` | ignored as active truth; route still governs |
| `tenant_scoped_evidence` | redirect to chooser if workspace truth cannot be re-established | redirect to `admin.evidence.overview` when tenant detail context is no longer valid | redirect to `admin.evidence.overview` | clear remembered tenant and return to `admin.evidence.overview` |
| `canonical_workspace_record_viewer` | 404 if the record itself is no longer entitled | 404 if tenant-scoped record access fails | remain on `admin.operations.view` when it is still workspace-safe, otherwise use the documented workspace fallback | clear remembered tenant and keep record-only rules |
## Documented Recovery Destinations
| RecoveryAction | Route target | When it applies |
|---|---|---|
| `redirect_choose_workspace` | `/admin/choose-workspace` | Missing or unrecoverable workspace truth at shell entry or restore time |
| `redirect_operations_index` | `admin.operations.index` | External or missing referrer, clear-flow sentinel path, and generic workspace-safe fallback for tenantless monitoring entry |
| `redirect_evidence_overview` | `admin.evidence.overview` | Tenant-scoped evidence paths that must return to a workspace-safe evidence landing |
| `redirect_workspace_managed_tenants` | `admin.workspace.managed-tenants.index` | Tenant-bound cleanup or workspace switch flows that must return to tenant selection inside the resolved workspace |
| `redirect_workspace_home` | `admin.home` | Tenant-bound cleanup when no current workspace truth remains available |
| `redirect_workspace_record_fallback` | `admin.operations.view` in the current-release scope | Canonical workspace record viewers that stay in workspace scope without reviving tenant truth |
## State Transitions
| Trigger | From | To | Notes |
|---|---|---|---|
| Workspace switch | `tenant_scoped` | `tenant_scoped` or `tenantless_workspace` | Existing tenant survives only after compatibility re-check in target workspace. |
| Tenant select | `tenantless_workspace` | `tenant_scoped` | Explicit user action; requires entitlement and operability validation. |
| Tenant clear on workspace page | `tenant_scoped` | `tenantless_workspace` | Valid only on workspace-scoped pages. |
| Tenant clear on tenant-bound page | `tenant_scoped` | `tenantless_workspace` plus redirect | Redirect destination depends on page category and route family. |
| Remembered tenant invalidation | `tenant_scoped` or restore candidate | `tenantless_workspace` | Candidate is cleared and cannot stay visible. |
| Workspace invalidation | any | `missing_workspace` or `invalid_workspace` | Recovery goes to chooser or not-found depending on entry path. |
## Display Semantics
| Resolved state | Workspace label | Tenant label | Action affordances |
|---|---|---|---|
| `tenant_scoped` | Active workspace name | Active tenant name | Switch workspace, Select tenant, Clear tenant context |
| `tenantless_workspace` | Active workspace name | `No tenant selected` | Switch workspace, Select tenant |
| `missing_workspace` / `invalid_workspace` | `Choose workspace` or recovery label | hidden or disabled | Choose workspace, optional safe return |
| `invalid_tenant` / `inaccessible_tenant` / `incompatible_tenant` | Active workspace name if valid | no stale tenant name shown | Recovery action only; no stale tenant truth |

View File

@ -0,0 +1,323 @@
# Implementation Plan: Global Context Shell Contract
**Branch**: `199-global-context-shell-contract` | **Date**: 2026-04-18 | **Spec**: `specs/199-global-context-shell-contract/spec.md`
**Input**: Feature specification from `specs/199-global-context-shell-contract/spec.md`
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 admin and tenant panels, reuses `WorkspaceContext` as the session-backed storage owner, promotes one request-scoped shell resolution contract over the current split logic, and explicitly avoids new persistence, panel proliferation, or a generic context framework.
## Summary
Cut one explicit workspace-first global shell contract for `/admin` and `/admin/t/{external_id}` by keeping workspace session ownership in `WorkspaceContext`, consolidating active workspace and tenant resolution behind one request-scoped shell contract consumed by `OperateHubShell`, aligning switch/select/clear controllers and `EnsureFilamentTenantSelected` to the same precedence and fallback rules, and reducing the shared `context-bar` partial to a pure consumer and dispatcher of resolved context. Preserve tenant-safe global search, deny-as-not-found isolation, intended-URL handling, and existing Filament panel topology while replacing scattered partial, controller, middleware, and panel-state heuristics with one documented source-of-truth hierarchy and one explicit invalid-context recovery model.
## Contract Ownership
- **Source inventory owner**: `specs/199-global-context-shell-contract/data-model.md` contains the canonical `Context Source Inventory` for every in-scope workspace and tenant context source.
- **Documented recovery destinations**:
- missing or unrecoverable workspace truth falls back to `/admin/choose-workspace`
- generic referrer-free or sentinel cleanup falls back to `admin.operations.index`
- tenant-scoped evidence cleanup falls back to `admin.evidence.overview`
- tenant-bound cleanup with a valid workspace falls back to `admin.workspace.managed-tenants.index`
- tenant-bound cleanup without recoverable workspace falls back to `admin.home`
- tenantless-capable workspace routes and canonical workspace record viewers remain on their current route when entitlement remains valid
- **Workspace switch destination set**:
- safe intended `/admin...` URL when present and still valid
- `admin.workspace.managed-tenants.index` when the resolved workspace has zero selectable tenants
- `/admin/choose-tenant` when the resolved workspace has multiple selectable tenants
- tenant dashboard route under `/admin/t/{external_id}` when the resolved workspace has exactly one selectable tenant
- **Explicit page-category exceptions**:
- `/admin/choose-workspace` is the `workspace_chooser_exception` route
- `/admin/evidence/...` except `/admin/evidence/overview` is treated as `tenant_scoped_evidence` for recovery behavior
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `WorkspaceContext`, `OperateHubShell`, `EnsureFilamentTenantSelected`, `WorkspaceRedirectResolver`, `WorkspaceIntendedUrl`, `TenantPageCategory`, and `ResolvesPanelTenantContext`
**Storage**: PostgreSQL unchanged plus existing Laravel session keys `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`; no schema change planned
**Testing**: Pest unit and feature tests, existing Filament or Livewire page tests, and manual shell smoke validation through Laravel Sail; browser automation remains optional and not the proving default
**Validation Lanes**: fast-feedback, confidence
**Target Platform**: Laravel monolith web application under `apps/platform`, running via Sail locally, with shared operator shells on `/admin` and `/admin/t/{external_id}` and isolated `/system` remaining out of scope
**Project Type**: web application
**Performance Goals**: Keep shell context resolution request-scoped, DB-and-session-only at render time, avoid any outbound HTTP or queued work during shell hydration, avoid additional N+1 tenant lookups in the topbar, and keep context-bar rendering within existing operator-page latency expectations
**Constraints**: No new persisted truth, no generic context engine, no cross-plane auth redesign, no hidden page-state ownership inside the shell contract, no global navigation rewrite, no dependency changes, and no new asset pipeline requirements
**Scale/Scope**: 2 shared operator panels, 1 shared context-bar partial, 3 context mutation endpoints, 5 existing core support classes or middleware seams, targeted updates across existing workspace, monitoring, RBAC, and tenant-RBAC feature seams, and 2 to 4 new narrow regression files for shell resolution and recovery
## 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 | The feature changes shell context truth only. It does not alter inventory, snapshot, or backup product truth. |
| Read/write separation | PASS | PASS | The work changes session-backed scope selection and recovery behavior only. No new Microsoft tenant write, queued work, or operational mutation is introduced. |
| Graph contract path | N/A | N/A | No Microsoft Graph call path is added or modified. |
| Deterministic capabilities | PASS | PASS | Existing workspace and tenant entitlement checks remain authoritative. No new capability strings or auth planes are introduced. |
| Workspace + tenant isolation | PASS | PASS | The design strengthens workspace-first isolation by preventing tenant truth from surviving outside a valid workspace and by standardizing 404 versus tenantless fallback rules. |
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain 404, members without capability remain 403 only after scope is established, and shell context cleanup must not surface inaccessible tenant or workspace truth. |
| Run observability / Ops-UX | PASS | PASS | No `OperationRun` is introduced or changed. Shell resolution and display remain synchronous request work. |
| Data minimization | PASS | PASS | No new persistence, cache mirror, or derived artifact is added; remembered values remain support state only. |
| Proportionality / anti-bloat | PASS WITH JUSTIFIED SOURCE CONTRACT | PASS WITH JUSTIFIED SOURCE CONTRACT | The feature introduces one bounded source-of-truth hierarchy and one bounded runtime state vocabulary because multiple existing classes already resolve the same context with competing rules. It explicitly avoids a generic engine or persisted context model. |
| UI semantics / few layers | PASS | PASS | The plan keeps one thin request-scoped contract and removes partial-owned context logic instead of adding a presenter or UI framework layer. |
| Filament-native UI | PASS | PASS | Existing Filament panels, routes, middleware, and shared partials remain the implementation path. No hand-built alternate shell system is introduced. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched surfaces remain on Filament v5 and Livewire v4 semantics only. |
| Provider registration location | PASS | PASS | No provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced. Existing searchable resources keep their View or Edit pages, and the contract explicitly preserves tenant-safe global search behavior under resolved shell scope. |
| Destructive action safety | PASS | PASS | No new destructive Filament action is added. Context reset via `clear-tenant-context` remains a scope action, not a record-destruction action. |
| Asset strategy | PASS | PASS | No new assets or build steps are planned. Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient. |
## Filament-Specific Compliance Notes
- **Livewire v4.0+ compliance**: The implementation stays on Filament v5 and Livewire v4 pages, middleware, support classes, and shared Blade partials. No legacy Livewire or Filament APIs are introduced.
- **Provider registration location**: No panel or provider registration changes are planned. Provider registration remains in `bootstrap/providers.php`.
- **Global search**: No new searchable resources are added. Existing searchable resources remain `TenantResource` and `PolicyResource`, both of which already have View pages, and the shell contract must preserve existing tenant-safe and workspace-safe global search semantics.
- **Destructive actions**: The feature introduces no destructive record actions. Existing shell actions `Switch workspace`, `Select tenant`, and `Clear tenant context` remain scope-setting flows only. Any existing destructive actions elsewhere remain unchanged and continue to require confirmation and authorization under current resource contracts.
- **Asset strategy**: No new JS or CSS assets are planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
- **Testing plan**: Extend or reuse `WorkspaceContextRememberedTenantTest`, `WorkspaceContextTopbarAndTenantSelectionTest`, `WorkspaceContextRecoveryDisplayTest`, `SelectTenantControllerTest`, `ChooseTenantPageTest`, `ChooseWorkspacePageTest`, `ChooseWorkspaceRedirectsToChooseTenantTest`, `WorkspaceRedirectResolverTest`, `WorkspaceSwitchUserMenuTest`, `SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest`, `SwitchWorkspaceControllerTest`, `GlobalContextShellContractTest`, `EnsureWorkspaceSelectedMiddlewareTest`, `WorkspacesResourceIsTenantlessTest`, `OperationsIndexHeaderTest`, `AdminGlobalSearchContextSafetyTest`, `TenantSwitcherScopeTest`, `TenantActionSurfaceConsistencyTest`, `OperationsDbOnlyRenderTest`, and `OperationsActionsEnqueueRunTest` so the contract is proven without introducing a new browser or heavy-governance family.
## Test Governance Check
- **Test purpose / classification by changed surface**: Unit for runtime context resolution and invalidation rules in support classes; Feature for controller flows, middleware behavior, panel entry routes, and shared shell rendering.
- **Affected validation lanes**: fast-feedback, confidence.
- **Why this lane mix is the narrowest sufficient proof**: The feature changes request-time scope resolution, redirects, session mutation, and rendered shell truth. These are best proven with unit tests around the support layer plus feature tests over routes and rendered pages. A browser lane is not required unless later implementation introduces client-only shell behavior that feature tests cannot observe.
- **Narrowest proving command(s)**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SelectTenantControllerTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseTenantPageTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspacePageTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Spec085/OperationsIndexHeaderTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsActionsEnqueueRunTest.php`
- **Fixture / helper / factory / seed / context cost risks**: Moderate but bounded. Tests need workspace membership, tenant membership, workspace session state, remembered tenant session state, and selective Filament tenant seeding. No new provider bootstraps, seeds, queues, or browser fixtures are expected.
- **Expensive defaults or shared helper growth introduced?**: No. Existing helper seams such as `createUserWithTenant()` and targeted session seeding stay opt-in. The plan should avoid introducing a global full-shell fixture as the new default.
- **Heavy-family additions, promotions, or visibility changes**: none.
- **Non-functional shell-render proof**: Reuse `OperationsDbOnlyRenderTest` and `OperationsActionsEnqueueRunTest` so the shell-anchor workspace surfaces stay DB-only and non-enqueuing during render.
- **Closing validation and reviewer handoff**: Reviewers should confirm that new tests stay in unit and feature lanes, that invalid-context recovery is proven without browser escalation, that shell display and actual resolved context match, and that remembered-tenant invalidation remains explicit in test names and assertions.
- **Budget / baseline / trend follow-up**: none beyond a small increase in focused unit and feature runtime.
- **Review-stop questions**: Is the new proof actually feature-level? Did any helper make full workspace or tenant context implicit by default? Did the implementation create a second source of truth in tests or UI? Did any browser-only assertion sneak in without necessity?
- **Escalation path**: document-in-feature.
- **Why no dedicated follow-up spec is needed**: Test cost remains feature-local as long as the work stays in the existing support layer, controllers, middleware, and shared shell partials without introducing a new test harness or a new browser family.
## Phase 0 Research
Research outcomes are captured in `specs/199-global-context-shell-contract/research.md`.
Key decisions:
- Keep `WorkspaceContext` as the session-backed owner of workspace selection and remembered tenant storage, but stop treating it and `OperateHubShell` as parallel visible truths.
- Use one request-scoped resolved shell contract to unify route tenant, Filament tenant, remembered tenant, and tenantless fallback semantics instead of repeating those rules in controllers, partials, and middleware.
- Preserve the existing effective precedence for active tenant on admin surfaces: valid route tenant first, then explicit tenant selection, then validated query-backed tenant hints only on explicitly allowed workspace-scoped routes, then validated Filament tenant, then remembered tenant only on workspace-scoped pages, with tenant-bound pages rejecting query-hint and remembered fallback.
- Reduce `context-bar.blade.php` to a pure consumer and dispatcher of resolved context instead of letting it re-discover state on its own.
- Make invalid-context recovery explicit and page-category-aware so missing workspace, missing tenant, incompatible tenant, and inaccessible tenant produce deterministic fallback rather than mixed 404, silent clear, or stale shell display behavior.
- Extend existing unit and feature seams instead of introducing a browser-first shell test family.
## Phase 1 Design
Design artifacts are created under `specs/199-global-context-shell-contract/`:
- `research.md`: shell source-of-truth decisions, risks, and rejected alternatives
- `data-model.md`: runtime shell-context entities, validation rules, and state transitions
- `contracts/global-context-shell.logical.openapi.yaml`: internal logical HTTP contract for shell context entry and mutation flows
- `quickstart.md`: implementation and verification workflow for Spec 199
Design highlights:
- Keep the contract derived and request-scoped. No new table or persisted shell artifact is introduced.
- Preserve `WorkspaceContext` as the storage owner and existing route controllers as explicit mutation entry points, but make them consume one resolved shell contract instead of each re-defining fallback behavior.
- Treat `OperateHubShell` as the canonical shared shell resolver for admin-facing context, with tenant-panel-native semantics remaining route-bound and panel-native where appropriate.
- Keep the workspace chooser flow as the explicit current-release `workspace_chooser_exception` instead of letting missing-workspace handling stay implicit.
- Encode invalid-context recovery as explicit outcome types tied to route requirements and `TenantPageCategory`, instead of leaving recovery scattered across `context-bar.blade.php`, `ClearTenantContextController`, and middleware heuristics.
- Keep page-local filters, tabs, inspect state, and other page-state concerns out of the shell contract so tenant-prefilter behavior remains explicit and opt-in.
- Keep `EnsureFilamentTenantSelected` and `ResolvesPanelTenantContext` as consumers of the contract so shared panel behavior does not drift.
## Phase 1 - Agent Context Update
Executed command:
- `.specify/scripts/bash/update-agent-context.sh copilot`
This feature does not add a new language or framework, but the agent-context refresh still runs after design artifacts are complete so the current feature context is recorded in the agent guidance files.
## Project Structure
### Documentation (this feature)
```text
specs/199-global-context-shell-contract/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── global-context-shell.logical.openapi.yaml
├── checklists/
│ └── requirements.md
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ └── Concerns/
│ │ ├── ResolvesPanelTenantContext.php # MODIFY
│ │ └── ScopesGlobalSearchToTenant.php # REUSE / possible small adjust
│ ├── Http/
│ │ └── Controllers/
│ │ ├── SwitchWorkspaceController.php # MODIFY
│ │ ├── SelectTenantController.php # MODIFY
│ │ └── ClearTenantContextController.php # MODIFY
│ ├── Providers/
│ │ └── Filament/
│ │ ├── AdminPanelProvider.php # REUSE / possible small adjust
│ │ └── TenantPanelProvider.php # REUSE / possible small adjust
│ └── Support/
│ ├── Middleware/
│ │ └── EnsureFilamentTenantSelected.php # MODIFY
│ ├── OperateHub/
│ │ └── OperateHubShell.php # MODIFY
│ ├── Tenants/
│ │ └── TenantPageCategory.php # MODIFY
│ └── Workspaces/
│ ├── WorkspaceContext.php # MODIFY
│ ├── WorkspaceIntendedUrl.php # REUSE / possible small adjust
│ └── WorkspaceRedirectResolver.php # MODIFY
├── resources/
│ └── views/
│ └── filament/
│ └── partials/
│ └── context-bar.blade.php # MODIFY
└── tests/
├── Unit/
│ └── Support/
│ ├── OperateHub/
│ │ └── OperateHubShellResolutionTest.php # NEW
│ └── Workspaces/
│ └── WorkspaceContextRememberedTenantTest.php # MODIFY
└── Feature/
├── Filament/
│ ├── WorkspaceContextTopbarAndTenantSelectionTest.php # MODIFY
│ └── WorkspaceContextRecoveryDisplayTest.php # NEW
├── Monitoring/
│ ├── OperationsActionsEnqueueRunTest.php # MODIFY / VERIFY
│ └── OperationsDbOnlyRenderTest.php # MODIFY / VERIFY
├── Rbac/
│ ├── AdminGlobalSearchContextSafetyTest.php # MODIFY
│ └── TenantActionSurfaceConsistencyTest.php # MODIFY
├── Spec085/
│ └── OperationsIndexHeaderTest.php # MODIFY
├── TenantRBAC/
│ └── TenantSwitcherScopeTest.php # MODIFY
└── Workspaces/
├── ChooseTenantPageTest.php # MODIFY
├── ChooseWorkspacePageTest.php # MODIFY
├── ChooseWorkspaceRedirectsToChooseTenantTest.php # MODIFY
├── EnsureWorkspaceSelectedMiddlewareTest.php # MODIFY
├── GlobalContextShellContractTest.php # NEW
├── SelectTenantControllerTest.php # MODIFY
├── SwitchWorkspaceControllerTest.php # NEW
├── WorkspaceRedirectResolverTest.php # MODIFY
├── SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php # MODIFY
├── WorkspaceSwitchUserMenuTest.php # MODIFY
└── WorkspacesResourceIsTenantlessTest.php # MODIFY
```
**Structure Decision**: Keep the work entirely inside the existing Laravel and Filament monolith under `apps/platform`. Reuse current support classes, controllers, middleware, panel providers, and the shared `context-bar` partial. Add only narrow new tests and, if the implementation proves it necessary, a tiny request-scoped result structure inside the existing support layer rather than a new framework directory.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Bounded new source-of-truth contract and runtime shell-context taxonomy | Multiple existing classes and the shared shell partial already resolve active workspace and tenant context with competing precedence and recovery rules. The feature needs one explicit request-scoped contract to stop drift now. | Pure controller or partial cleanup would keep resolution logic split across `WorkspaceContext`, `OperateHubShell`, middleware, panel state, and the Blade partial, which would preserve the exact ambiguity Spec 199 exists to remove. |
## Proportionality Review
- **Current operator problem**: Operators and reviewers cannot reliably trust the shell to answer where they are operating, which tenant is active, or what a switch or clear action will do.
- **Existing structure is insufficient because**: The current rules live across `WorkspaceContext`, `OperateHubShell`, controllers, middleware, panel tenancy, and `context-bar.blade.php`, so local cleanup in one place cannot produce one shared source of truth.
- **Narrowest correct implementation**: Keep workspace and remembered-tenant storage in the current session-backed support layer, introduce one explicit resolved shell contract for the request, and make existing shell consumers use it. Do not add persistence or a generic engine.
- **Ownership cost created**: Reviewers must maintain one shared shell source hierarchy, one invalid-context taxonomy, and focused unit and feature regression coverage for switch, select, clear, restore, and recovery.
- **Alternative intentionally rejected**: A generic multi-panel context framework was rejected as overproduction, and a partial-only cleanup was rejected as insufficient.
- **Release truth**: current-release operator trust and scope clarity
## Implementation Strategy
### Phase A - Canonicalize one resolved shell context
**Goal**: Replace competing runtime context truths with one request-scoped resolved contract while keeping existing session-backed storage ownership intact.
| Step | File | Change |
|------|------|--------|
| A.0 | `specs/199-global-context-shell-contract/data-model.md` | Maintain the canonical `Context Source Inventory` so every in-scope source has one declared role, one owner, and one validation note. |
| A.1 | `apps/platform/app/Support/OperateHub/OperateHubShell.php` | Expand the shell support seam so it can resolve one canonical shell context for the current request, including workspace, tenant, page category, source precedence, tenantless validity, and invalid-context recovery metadata. |
| A.2 | `apps/platform/app/Support/Workspaces/WorkspaceContext.php` | Keep workspace and remembered-tenant session ownership here, but align helper methods to the canonical contract by making remembered values restore-only and by making invalidation rules explicit and reusable. |
| A.3 | `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php` | Make admin-panel tenant context consumption route through the canonical resolved shell contract instead of ad hoc tenant lookup. |
| A.4 | `apps/platform/tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php` and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php` | Add or extend unit coverage for route-first, Filament-tenant, remembered-tenant, tenantless, and invalid remembered-context branches. |
### Phase B - Align explicit scope mutation flows
**Goal**: Make switch, select, clear, restore, and intended-return behavior follow the same source hierarchy and fallback matrix.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Http/Controllers/SwitchWorkspaceController.php` and `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` | Ensure workspace switching re-evaluates tenant compatibility deterministically, clears incompatible tenant state, and uses one redirect strategy after intended-URL consumption. |
| B.2 | `apps/platform/app/Http/Controllers/SelectTenantController.php` | Keep explicit tenant selection as the only user-driven tenant activation flow on workspace-level pages, but align it with the canonical shell contract, selector-operability rules, and recovery semantics. |
| B.3 | `apps/platform/app/Http/Controllers/ClearTenantContextController.php` and `apps/platform/app/Support/Tenants/TenantPageCategory.php` | Standardize tenant-clear recovery and route compatibility rules so tenant-required pages, workspace pages, evidence paths, and canonical record viewers resolve either to same-route tenantless workspace state or to the documented destinations `admin.workspace.managed-tenants.index`, `admin.evidence.overview`, `admin.operations.index`, `admin.operations.view`, or `admin.home` as appropriate. |
| B.4 | `apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php` | Reuse or slightly refine intended-URL handling so switch and recovery flows return to safe shell-compatible destinations only. |
| B.5 | `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php`, `SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php`, `WorkspaceRedirectResolverTest.php`, `SelectTenantControllerTest.php`, and `ChooseWorkspacePageTest.php` | Cover workspace switch redirects, explicit tenant selection, workspace-independent chooser exceptions, tenant compatibility, and invalid or inaccessible context requests. |
### Phase C - Make the shell surfaces consume the contract
**Goal**: Reduce the shared shell UI to one truthful display and action entry point.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/resources/views/filament/partials/context-bar.blade.php` | Remove partial-owned source precedence and make the topbar display and controls derive only from the canonical resolved shell context and explicit available actions. |
| C.2 | `apps/platform/app/Providers/Filament/AdminPanelProvider.php` and `TenantPanelProvider.php` | Keep the shared render-hook strategy, but adjust only if needed so both panels consume the same shared shell contract without panel-specific truth drift. |
| C.3 | `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `OperationsIndexHeaderTest.php`, and `WorkspaceContextRecoveryDisplayTest.php` | Cover active tenant display, explicit tenantless display, stale or inaccessible remembered context clearing, and panel-consistent shell output. |
### Phase D - Harden page-category, middleware, and scope-safety behavior
**Goal**: Ensure that route type and access boundaries determine whether tenantless fallback, redirect, or 404 is correct.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php` | Replace ad hoc tenant-selection and navigation heuristics with canonical shell-context checks while preserving tenant-bound route enforcement and workspace isolation. |
| D.2 | `apps/platform/app/Support/Tenants/TenantPageCategory.php` | Tighten route categorization only where current path-pattern rules are too implicit for the new invalid-context matrix, including the explicit `workspace_chooser_exception` and `tenant_scoped_evidence` cases. |
| D.3 | `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php` | Add focused feature coverage for missing workspace, missing tenant, invalid tenant, inaccessible tenant, tenant-bound route fallback, and workspace-scoped tenantless behavior. |
### Phase E - Close with regression protection and operator verification
**Goal**: Leave the repo with one documented shell contract, narrow regression coverage, and a clear manual validation path.
| Step | File | Change |
|------|------|--------|
| E.1 | Existing unit and feature suites listed above | Extend current tests instead of creating a browser-heavy new family. Keep resolution, redirect, and display assertions explicit in names and expectations. |
| E.2 | `apps/platform/tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php` and `apps/platform/tests/Feature/Monitoring/OperationsActionsEnqueueRunTest.php` | Keep shell-anchor workspace rendering DB-only and non-enqueuing so the contract does not widen runtime behavior accidentally. |
| E.3 | `specs/199-global-context-shell-contract/quickstart.md` | Record implementation order, manual smoke steps, documented fallback targets, and exact verification commands for workspace switch, tenant select, tenant clear, invalid recovery, panel parity, and non-functional render proof. |
| E.4 | `specs/199-global-context-shell-contract/tasks.md` | Break work into dependency-ordered tasks after this plan is accepted. |
## Key Design Decisions
### D-001 - `WorkspaceContext` remains the storage owner, not the visible shell owner
The session-backed workspace and remembered-tenant state already live in `WorkspaceContext`. The right move is to keep it as the storage owner and validation seam, not replace it with persistence or a new framework.
### D-002 - One request-scoped shell contract must replace partial-owned precedence
The shell currently re-discovers scope inside Blade, middleware, and controllers. The plan centralizes that into one request-scoped resolved contract consumed everywhere else.
### D-003 - Route-bound tenant remains strongest on tenant-required surfaces
The existing effective precedence already treats route tenant as strongest, then validated panel tenant, then remembered tenant only on workspace-scoped pages. The plan keeps that precedence but makes it explicit and testable.
### D-004 - Tenant clear and invalid-context recovery need the same route-compatibility matrix
The product currently mixes previous-URL redirecting, silent remembered-context clearing, and 404 behavior. The plan aligns tenant clear and invalid recovery under one page-category-aware outcome matrix.
### D-005 - The context bar becomes a consumer and dispatcher only
The shared shell partial should show the resolved contract and expose explicit switch, select, and clear actions, but it should not be allowed to own a second context truth.

View File

@ -0,0 +1,173 @@
# Quickstart: Global Context Shell Contract
## Goal
Implement Spec 199 by making workspace and tenant shell context resolve from one request-scoped contract, then verify that switch, select, clear, restore, and invalid-context flows all produce the same truth the shell displays.
## Prerequisites
1. Start the app stack:
```bash
cd apps/platform && ./vendor/bin/sail up -d
```
2. Confirm the working branch:
```bash
git branch --show-current
```
3. Keep the current scope of work bounded to the existing Laravel and Filament monolith under `apps/platform`.
## Recommended Implementation Order
1. **Canonicalize resolution first**
- Align `WorkspaceContext` and `OperateHubShell` so they can produce one resolved shell contract.
- Make remembered context restore-only and remove any equal-ranking shell truth outside the resolved contract.
2. **Align explicit mutation flows second**
- Update `SwitchWorkspaceController`, `SelectTenantController`, `ClearTenantContextController`, and `WorkspaceRedirectResolver` to consume the same contract rules.
- Keep safe intended-URL behavior via `WorkspaceIntendedUrl`.
3. **Convert shell surfaces third**
- Update `context-bar.blade.php` and any panel concern or middleware consumer to render only the resolved contract.
- Preserve tenantless workspace behavior on routes that support it.
4. **Close with regression coverage**
- Extend current unit and feature seams before adding any new test family.
- Use browser testing only if a client-only shell behavior appears that feature tests cannot observe.
## Context Source Inventory Owner
Keep the canonical source inventory in `specs/199-global-context-shell-contract/data-model.md` under `Context Source Inventory`. Any new source or fallback seam added during implementation must be recorded there before tasks are considered complete.
## Documented Recovery Destinations
- Missing or unrecoverable workspace truth goes to `/admin/choose-workspace`.
- Generic workspace-safe recovery with no trustworthy prior route goes to `admin.operations.index`.
- Tenant-scoped evidence cleanup goes to `admin.evidence.overview`.
- Tenant-bound cleanup with a valid workspace goes to `admin.workspace.managed-tenants.index`.
- Tenant-bound cleanup with no recoverable workspace goes to `admin.home`.
- Tenantless-capable workspace routes and canonical workspace record viewers stay on their current route when entitlement remains valid.
## Explicit Page-Category Exceptions
- `/admin/choose-workspace` is the explicit `workspace_chooser_exception` route.
- Tenant-scoped evidence paths under `/admin/evidence/...` except `/admin/evidence/overview` are explicit `tenant_scoped_evidence` routes for recovery purposes.
## Documented Workspace Switch Destinations
- A safe intended `/admin...` URL wins when it is still valid.
- Workspaces with zero selectable tenants land on `admin.workspace.managed-tenants.index`.
- Workspaces with multiple selectable tenants land on `/admin/choose-tenant`.
- Workspaces with exactly one selectable tenant land on the tenant dashboard route under `/admin/t/{external_id}`.
## Focused Validation Commands
Run the narrowest commands that prove the contract:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SelectTenantControllerTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseTenantPageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspacePageTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/GlobalContextShellContractTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Spec085/OperationsIndexHeaderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsDbOnlyRenderTest.php
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Monitoring/OperationsActionsEnqueueRunTest.php
```
If new focused tests are added for Spec 199, run them directly as well. The two Monitoring commands above are the non-functional proof that the shell-anchor workspace surfaces remain DB-only and do not enqueue work while rendering.
## Formatting
Before closing the feature work:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Manual Smoke Checklist
Use a simple 3-second timer for each first-look shell scan on the in-scope entry paths so the SC-001 review stays measurable.
### 1. Workspace-scoped tenantless entry
- Enter `/admin/operations` with a valid workspace and no active tenant.
- Confirm the shell shows the workspace name and `No tenant selected` or the approved tenantless wording.
- Confirm no stale tenant label appears.
### 2. Explicit tenant selection
- Select a valid active tenant from the shared shell surface.
- Confirm the destination route and shell both show the same tenant.
- Confirm the tenant belongs to the active workspace only.
### 3. Tenant clear from a workspace-scoped page
- Clear tenant context while on `/admin/operations` or another workspace-scoped page.
- Confirm the shell becomes tenantless and the page remains valid.
### 4. Tenant clear from a tenant-bound page
- Clear tenant context while on a tenant-bound route.
- Confirm the request does not leave the user in a half-valid tenant-bound route.
- Confirm the redirect lands in `admin.workspace.managed-tenants.index` for the current workspace, or `admin.home` when no workspace truth remains.
### 4a. Tenant clear from a tenant-scoped evidence path
- Clear tenant context while on a tenant-scoped evidence path under `/admin/evidence/...`.
- Confirm the redirect lands on `admin.evidence.overview` and no stale tenant label remains in the shell.
### 4b. Tenant clear from a canonical workspace record viewer
- Clear tenant context while on `/admin/operations/{run}`.
- Confirm the request stays on `admin.operations.view` when entitlement remains valid and does not widen into a different route unnecessarily.
### 5. Invalid remembered tenant
- Seed a remembered tenant that is inaccessible, missing, or incompatible.
- Confirm the remembered tenant is cleared automatically and does not reappear in the shell.
### 6. Workspace switch with stale tenant context
- Switch from one workspace to another where the prior tenant is not valid.
- Confirm the shell clears tenant context or replaces it only after validation in the new workspace.
### 7. Workspace-independent chooser route
- Enter the workspace chooser flow without an active workspace.
- Confirm the route remains available as an explicit exception and is not treated as a generic missing-workspace failure.
### 8. Admin versus tenant panel parity
- Resolve the same valid tenant scenario through `/admin` and `/admin/t/{external_id}`.
- Confirm the shared shell displays the same active truth and does not expose a competing panel-owned context label.
## Done Signal
Spec 199 is implementation-ready when:
- one resolved shell contract governs display and route behavior,
- switch, select, clear, restore, and invalid recovery follow one shared rule set,
- the shared shell renders only the resolved truth,
- targeted unit and feature tests pass,
- timed manual smoke checks confirm tenantless and tenant-scoped behavior are both explicit and understandable.

View File

@ -0,0 +1,81 @@
# Research: Global Context Shell Contract
## Decision 1 - Keep `WorkspaceContext` as the session-backed storage owner, but not as a competing visible shell truth
- **Decision**: `WorkspaceContext` remains the owner of `current_workspace_id`, `workspace_intended_url`, and `workspace_last_tenant_ids`, while one request-scoped shell contract becomes the only visible and consumable truth for the active workspace and tenant.
- **Rationale**: The current repo already uses `WorkspaceContext` correctly as the place where session-backed workspace and remembered-tenant state live. The ambiguity comes from visible shell truth being recomputed separately in `OperateHubShell`, middleware, controllers, and `context-bar.blade.php`, not from the storage location itself.
- **Alternatives considered**:
- Replace `WorkspaceContext` with a new persistence model: rejected because the spec explicitly disallows new persisted truth.
- Leave `WorkspaceContext` and `OperateHubShell` as parallel visible truths: rejected because that preserves the ambiguity Spec 199 exists to remove.
## Decision 2 - Preserve the current effective tenant precedence, but make it explicit and shared
- **Decision**: On admin-shell surfaces, active tenant resolution remains: valid route tenant first, then explicit tenant selection, then a validated query-backed tenant hint only on workspace-scoped routes that explicitly allow it, then validated `Filament::getTenant()`, then remembered tenant only on workspace-scoped pages, then explicit tenantless fallback. Tenant-bound pages do not accept query hints or remembered tenant fallback as normal active truth.
- **Rationale**: This is already the effective behavior in `OperateHubShell::resolveActiveTenant()`, and it matches the repo's workspace-first intent while keeping route-bound tenant requirements strongest where the route semantics demand them.
- **Alternatives considered**:
- Make remembered tenant equal to route or panel tenant: rejected because remembered context must remain support-only.
- Make Filament tenant always win even when route tenant is explicit: rejected because tenant-bound routes need explicit route authority.
## Decision 3 - Convert the context bar into a pure consumer and dispatcher of the resolved contract
- **Decision**: `context-bar.blade.php` should stop owning resolution rules and should render only the already resolved workspace and tenant state plus the explicit switch, select, and clear actions that mutate shell scope.
- **Rationale**: The current partial queries `WorkspaceContext`, `OperateHubShell`, `Filament::getTenant()`, route name, query tenant, and `TenantPageCategory` in one Blade file. That makes the shell UI a second source of truth and hides business rules in a rendering surface.
- **Alternatives considered**:
- Keep the partial as-is and only tweak labels: rejected because the issue is structural, not cosmetic.
- Move all logic into Alpine or client-side state: rejected because the authoritative context is server-side and request-scoped.
## Decision 4 - Invalid-context recovery must be explicit and page-category-aware
- **Decision**: Missing workspace, missing tenant, incompatible tenant, inaccessible tenant, and invalid remembered context should map to explicit recovery outcomes that depend on route type and page category rather than on ad hoc previous-URL heuristics.
- **Rationale**: The current repo mixes silent remembered-context clearing, page-category redirect logic in `ClearTenantContextController`, and 404 behavior in middleware and route guards. That inconsistency is the operator-facing confusion Spec 199 is meant to remove.
- **Alternatives considered**:
- Always 404 on any invalid tenant state: rejected because workspace-scoped pages intentionally support a valid tenantless state.
- Always redirect to `/admin/operations`: rejected because tenant-bound routes and canonical record viewers need different recovery behavior.
## Decision 5 - Keep the solution in the current support layer instead of introducing a generic context engine
- **Decision**: The implementation should stay inside the existing support layer around `WorkspaceContext`, `OperateHubShell`, middleware, and controllers. If a new runtime object is needed, it should be a narrow request-scoped result structure only.
- **Rationale**: The repo already has the needed building blocks. The problem is coordination and precedence, not lack of extension points.
- **Alternatives considered**:
- New multi-panel context framework with registries, strategies, or factories: rejected under ABSTR-001 and PROP-001.
- Panel-specific duplicated fixes: rejected because the same shell contract spans both admin and tenant panels.
## Decision 6 - Reuse existing unit and feature seams as the primary proof strategy
- **Decision**: The proving default for Spec 199 is unit coverage around runtime resolution and feature coverage around controllers, middleware, and rendered shell surfaces. Browser automation stays optional and secondary.
- **Rationale**: The current repo already has targeted tests for remembered-tenant invalidation, context-bar display, choose-tenant behavior, redirect resolution, and clear-tenant fallbacks. Extending these seams is cheaper and more precise than introducing a new browser-heavy shell suite.
- **Alternatives considered**:
- Browser-first shell regression family: rejected because the contract is server-driven and the narrowest sufficient proof already exists in unit and feature seams.
- Manual-only verification: rejected because Spec 199 changes request-time contract behavior that should be regression-protected.
## Decision 7 - Preserve panel topology and cross-plane boundaries unchanged
- **Decision**: `/admin` and `/admin/t/{external_id}` continue to share the `web` guard and shared shell contract, while `/system` remains out of scope and isolated under the `platform` guard.
- **Rationale**: The spec is about global admin and tenant shell truth, not about re-cutting panel or guard boundaries.
- **Alternatives considered**:
- Fold tenant-panel behavior into `/admin` only: rejected because tenant-panel-native routing and tenancy already exist and remain valid.
- Expand the feature to `/system`: rejected because cross-plane behavior is a different product boundary.
## Decision 8 - Keep global search tenant-safe under the new shell contract without changing searchable resources
- **Decision**: The shell contract must preserve existing tenant-safe and workspace-safe global search behavior, but it does not introduce or remove searchable resources.
- **Rationale**: A context contract that widens tenant-owned global search results on workspace-scoped surfaces would violate the existing RBAC and tenant isolation guarantees.
- **Alternatives considered**:
- Ignore global search as unrelated: rejected because the resolved shell context influences whether tenant-owned search results are safe to return.
- Expand searchable resource scope during shell cleanup: rejected because the spec is about context truth, not search surface growth.
## Decision 9 - Keep the explicit source inventory in the feature data model artifact
- **Decision**: The canonical source inventory for Spec 199 lives in `data-model.md` under `Context Source Inventory`, not in controller comments or scattered plan prose.
- **Rationale**: The feature needs one maintained place that lists every in-scope source, its source role, the seam that owns it, and the validation boundary it must pass.
- **Alternatives considered**:
- Keep the source inventory implicit in the plan only: rejected because task generation and implementation reviews need a concrete artifact that can be updated without re-reading the entire plan narrative.
- Split the inventory across multiple code comments: rejected because that would recreate the same drift problem inside the documentation layer.
## Decision 10 - Document fallback destinations from the current route families instead of inventing abstract recovery targets
- **Decision**: The contract documents the existing workspace-safe fallback routes used by the product today: `admin.operations.index`, `admin.evidence.overview`, `admin.workspace.managed-tenants.index`, `admin.home`, and route-stable workspace record viewers where they remain valid.
- **Rationale**: The repo already has concrete fallback behavior in `ClearTenantContextController`, `WorkspaceRedirectResolver`, and route families around monitoring, evidence, and tenant selection. The contract should reflect that real product behavior instead of describing a generic fallback abstraction.
- **Alternatives considered**:
- Keep fallback wording abstract as “workspace-level fallback”: rejected because that leaves implementers and reviewers guessing about the actual destination.
- Collapse all recovery into `/admin/operations`: rejected because tenant-bound and evidence-specific flows already use more precise workspace-safe landings.

View File

@ -0,0 +1,356 @@
# Feature Specification: Global Context Shell Contract
**Feature Branch**: `199-global-context-shell-contract`
**Created**: 2026-04-18
**Status**: Proposed
**Input**: User description: "Spec 199 — Global Context Shell Contract"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: The admin and tenant shell currently derive active workspace and tenant scope from a mix of route input, query hints, panel tenant state, session-backed workspace context, remembered tenant values, and shell-local rendering logic instead of one explicit product contract.
- **Today's failure**: Operators and developers cannot reliably answer the same basic question across the shell: what scope is actually active, what a switch or clear flow will do, whether a deeplink is authoritative, and which fallback wins when context inputs disagree or become invalid.
- **User-visible improvement**: Operators see one clear workspace-first shell truth, one predictable tenant truth inside that workspace, explicit tenantless states, and consistent switch, clear, restore, and fallback behavior across shared shell surfaces.
- **Smallest enterprise-capable version**: Map the current context sources and shell entry points, define one resolved context contract for workspace and tenant, align the context bar and switch/clear flows to that contract, add focused regression coverage for resolution and fallback, and document what remains page-state or constitution work.
- **Explicit non-goals**: No page-level tab/filter/inspect-state contract, no generic Filament nativity cleanup, no global navigation or IA rewrite, no detail micro-UI redesign, no new generic context platform engine, and no product-wide authorization redesign beyond shell context visibility and enforcement boundaries.
- **Permanent complexity imported**: A bounded shell-context taxonomy, an explicit source-of-truth hierarchy, documented switch/select/clear/fallback rules, a shared shell vocabulary for workspace and tenant state, and focused regression tests around those rules.
- **Why now**: The repo already has multiple context sources and shared shell surfaces across `/admin` and `/admin/t/{external_id}`. Adjacent specs intentionally leave this global shell context layer unresolved, so additional work will keep reintroducing drift unless the contract is cut now.
- **Why not local**: Local fixes inside one partial, one controller, or one panel would reduce a symptom, but would keep competing truths alive and would not give operators or reviewers a single product contract for scope resolution.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Cross-panel contract breadth and source-of-truth consolidation. Defense: the scope is tightly bounded to workspace and tenant shell truth, introduces no new persistence, and explicitly excludes broader navigation, page-state, and micro-UI cleanup work.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- `/admin`
- `/admin/choose-workspace`
- `/admin/choose-tenant`
- `/admin/switch-workspace`
- `/admin/select-tenant`
- `/admin/clear-tenant-context`
- `/admin/t/{external_id}/...`
- Shared shell surfaces rendered on the admin and tenant panels
- **Data Ownership**:
- This feature introduces no new tables, persisted entities, or storage truth.
- It standardizes the shell contract that governs how workspace-owned context and tenant-owned scope visibility are resolved on existing surfaces.
- Remembered workspace and tenant values remain convenience state only; they do not become independent product records.
- **RBAC**:
- Workspace membership is required before a workspace can become the active shell context.
- Tenant membership is required before a tenant can become the active tenant context.
- Non-membership remains deny-as-not-found and capability checks inside an established scope remain server-side authorization concerns.
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: Workspace-level pages may opt into an explicit tenant prefilter, but the shell contract itself must never inject hidden page-local filters. Tenant-bound routes remain explicitly tenant-scoped.
- **Explicit entitlement checks preventing cross-tenant leakage**: Route, query, remembered, and switch requests must pass workspace and tenant entitlement checks before they become active shell context. Invalid or inaccessible context requests must be discarded without leaking unavailable tenant truth.
## 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 |
|---|---|---|---|---|---|---|---|
| Global context bar / shell header | Secondary Context Surface | Confirm or change current scope before navigating, reviewing, or mutating anything else | Active workspace, active tenant or explicit tenantless state, current shell scope, available switch affordance | Available workspaces, available tenants, and any intentionally surfaced recovery hint | Not primary because it frames decisions across the product rather than owning a domain-specific decision queue | Aligns `/admin` and `/admin/t/{external_id}` entry points around one scope model | Removes repeated “where am I?” reconstruction and hidden scope drift between pages |
| Context recovery shell state | Secondary Context Surface | Recover from missing, invalid, inaccessible, or incompatible context before work continues | Missing or invalid scope state, actual fallback scope, and the next required action | Optional explanation of why a requested or remembered context was discarded | Not primary because it is a recovery state of the shell contract, not a business workbench | Aligns missing-context behavior instead of letting each page improvise its own fallback | Prevents confusing half-active scope states and reduces retry-based navigation |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Global context bar / shell header | Navigation / Context / Shell | Global scope switcher | Confirm current scope or intentionally switch workspace or tenant | One inline shell context strip with one menu-driven switch model | forbidden | Inside the context controls only | none; tenant clear is a scope-reset action, not a data-destructive action | `/admin` and `/admin/t/{external_id}` shared shell | none | Active workspace, active tenant or `No tenant selected`, current panel scope | Context / workspace / tenant | The single resolved workspace and tenant truth governing the shell right now | none |
| Context recovery shell state | Navigation / Context / Recovery | Missing or invalid scope recovery | Choose a valid workspace, return to a workspace-level route, or clear invalid tenant context | One inline recovery state in the same shell contract | forbidden | Recovery controls inside the shell state only | none | `/admin` and `/admin/t/{external_id}` shared shell | none | Missing workspace, missing tenant, invalid request, incompatible tenant, inaccessible tenant | Context recovery | Why the requested scope cannot be honored and what valid scope remains | 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Global context bar / shell header | Workspace operator, tenant operator | Confirm current scope and intentionally switch workspace or tenant | Shell context strip | Where am I operating right now, and can I safely change scope? | Active workspace, active tenant or explicit tenantless state, current panel scope, switch affordances | Why a requested context was ignored, which remembered value was eligible, and any compatibility note that must stay secondary | workspace presence, tenant presence, validity, source resolution | context only | Switch workspace, Select tenant, Clear tenant context | none |
| Context recovery shell state | Workspace operator, tenant operator | Recover from missing, invalid, inaccessible, or incompatible context | Shell recovery prompt | Why is this scope unavailable, and what is the valid next step? | Missing or invalid state, current fallback scope, next valid action | Discarded request reason and any intentionally surfaced restore candidate | missing, invalid, inaccessible, incompatible, tenantless | context only | Choose workspace, Return to workspace-level page, Clear tenant request | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no
- **New enum/state/reason family?**: yes
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators and reviewers face multiple competing answers for current scope, fallback, and restore behavior, so shell context cannot be trusted as a single product truth.
- **Existing structure is insufficient because**: The current shell can derive active context from route input, query hints, session state, panel state, and remembered values. Local cleanup in one place cannot define one shared switch, clear, restore, and fallback contract across panels.
- **Narrowest correct implementation**: Define one resolved shell-context contract over existing workspace and tenant context behavior, explicitly classify requested, remembered, resolved, tenantless, and invalid states, and align current shell surfaces and entry flows to it without new persistence or a generic engine.
- **Ownership cost**: The repo gains one source hierarchy to maintain, one shared shell vocabulary to preserve, and focused feature tests for context resolution, switch, clear, restore, and invalid-state handling.
- **Alternative intentionally rejected**: Local partial-only cleanup was rejected because it would preserve competing truths. A broader generic multi-panel context framework was rejected because it would import unnecessary abstraction for a bounded shell problem.
- **Release truth**: current-release operator truth and shell reliability
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit + Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: The change spans two proving purposes: unit tests for canonical source precedence, remembered-context invalidation, and page-category decisions in the support layer, plus feature tests for controller flows, middleware behavior, route recovery, and shared shell rendering. Together they prove runtime behavior without escalating to browser or heavy-governance lanes by default.
- **New or expanded test families**: Focused unit shell-context resolution tests, workspace-switch and tenant-selection controller tests, explicit workspace-chooser exception tests, and shared-shell display tests for admin and tenant panel entry paths.
- **Fixture / helper cost impact**: Minimal workspace, tenant, membership, entitlement, session, and remembered-context setup. No new providers, seeds, heavy browser harness, or long-running runtime fixtures are required.
- **Heavy-family visibility / justification**: none
- **Reviewer handoff**: Reviewers must confirm that coverage remains feature-level, that shell rendering is not accidentally pushed into a broad browser family, that invalid-context fallbacks are proven, and that the minimal commands below are enough to verify the contract.
- **Budget / baseline / trend impact**: Minor increase in focused feature-test runtime only.
- **Escalation needed**: none
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=GlobalContext`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=WorkspaceSwitch`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=TenantContext`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See The True Current Scope (Priority: P1)
As an operator, I want every shared shell surface to show the real active workspace and tenant state so I can trust where my next action will apply.
**Why this priority**: If the shell cannot answer the active scope question clearly, every downstream page remains harder to trust.
**Independent Test**: Open workspace-level and tenant-bound entry paths with valid and tenantless scenarios, then verify that the shell always shows the same resolved scope truth the target page is using.
**Acceptance Scenarios**:
1. **Given** a valid workspace and tenant context are active, **When** the shell renders on a shared admin or tenant panel surface, **Then** it shows that resolved workspace and tenant consistently.
2. **Given** a workspace-level page is active with no tenant selected, **When** the shell renders, **Then** it shows an explicit tenantless state instead of implying that a hidden tenant is still active.
---
### User Story 2 - Switch Workspace Without Stale Tenant Truth (Priority: P1)
As an operator, I want workspace switching to deterministically resolve what happens to tenant context so I do not carry a stale tenant into the wrong workspace or page.
**Why this priority**: Workspace is the primary scope. If workspace switching is ambiguous, the entire workspace-first model breaks down.
**Independent Test**: Start from a valid workspace and tenant, switch to a different workspace with compatible and incompatible tenant conditions, and verify the resulting scope, fallback, and destination page.
**Acceptance Scenarios**:
1. **Given** an operator switches to a different workspace and the prior tenant is not valid in that workspace, **When** the switch resolves, **Then** the shell clears tenant context and lands in a valid workspace-scoped state.
2. **Given** an operator switches to a workspace where a requested or remembered tenant is valid and allowed for the target surface, **When** the switch resolves, **Then** that tenant becomes active only after validation within the new workspace.
---
### User Story 3 - Select Or Clear Tenant Intentionally (Priority: P1)
As an operator, I want tenant selection and tenant clearing to behave like explicit scope decisions so I always understand whether I am entering tenant scope or returning to workspace-only scope.
**Why this priority**: Tenant is secondary but operationally critical. The select and clear flows are the most visible scope-changing actions in the shell.
**Independent Test**: Select a tenant from the shell, clear it from a workspace-level page, and clear it from a tenant-bound route to verify that the resulting shell truth and destination are deterministic.
**Acceptance Scenarios**:
1. **Given** an operator selects a valid tenant inside the active workspace, **When** the shell resolves the selection, **Then** the active tenant becomes that tenant and the shell shows the same tenant on the target page.
2. **Given** an operator clears tenant context while on a tenant-required route, **When** the clear flow resolves, **Then** the system redirects to the documented workspace-level fallback and the shell shows no active tenant.
---
### User Story 4 - Reject Invalid Or Stale Context Cleanly (Priority: P1)
As an operator, I want invalid, inaccessible, or stale requested and remembered context to fail cleanly so I do not operate under a false scope illusion.
**Why this priority**: Invalid context handling is where competing truths most often become visible and dangerous.
**Independent Test**: Enter the shell with invalid route, query, and remembered context combinations, then verify that the shell discards invalid inputs, lands in a valid fallback state, and never leaves stale scope indicators behind.
**Acceptance Scenarios**:
1. **Given** a route or query requests a tenant that does not belong to the resolved workspace, **When** the shell resolves context, **Then** that tenant request is rejected and the shell falls back to a valid workspace-scoped state.
2. **Given** a remembered tenant is no longer accessible or compatible, **When** the shell restores context, **Then** the remembered tenant is ignored and the operator sees an explicit valid fallback state.
---
### User Story 5 - Keep Shared Shell Logic Consistent Across Panels (Priority: P2)
As a reviewer, I want admin and tenant panel entry paths that share the shell contract to resolve context by the same rules so extensions do not create panel-specific truths.
**Why this priority**: The main risk is drift between shared shell surfaces and panel-specific context assumptions.
**Independent Test**: Resolve the same entitled workspace and tenant scenario through the workspace-level shell and tenant-bound shell entry paths, then verify that both surfaces display the same active truth and compatible fallback behavior.
**Acceptance Scenarios**:
1. **Given** the same workspace and tenant are active through different valid entry paths, **When** the shared shell renders, **Then** both panels show the same resolved scope truth.
2. **Given** a panel-specific context source disagrees with the resolved shell contract, **When** the shell renders, **Then** the panel-specific source is treated as supporting data only and does not become a competing visible truth.
### Edge Cases
- A route or query requests a tenant that belongs to a different workspace than the resolved workspace.
- Session-backed workspace state and requested workspace state disagree on first load.
- A remembered tenant exists for the prior workspace but not for the newly selected workspace.
- A tenant is cleared while the current page requires tenant scope.
- The explicit workspace chooser route is entered without workspace context and must not be mistaken for a generic missing-workspace error.
- A tenant becomes inaccessible between selection and the next request.
- A shell recovery state must distinguish between missing workspace, missing tenant, invalid tenant, and inaccessible tenant.
- Shared shell display and underlying page behavior disagree unless one explicit contract prevents the drift.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new persistent storage, and no new queued or scheduled operational workflow. It standardizes shell context resolution, display, and context-changing flows on existing surfaces.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces one bounded source-of-truth hierarchy and one bounded state taxonomy for global shell context. It does not introduce new persistence or a generic context framework. The proportionality review above explains why local cleanup is insufficient and why broader abstraction is rejected.
**Constitution alignment (TEST-GOV-001):** This feature changes runtime behavior and targeted tests. The proving purpose is feature-level validation of shell resolution, switch, clear, restore, and fallback behavior. The narrowest sufficient lanes are fast-feedback and confidence. No new browser or heavy-governance family is justified, fixture cost remains limited to workspace, tenant, membership, and session state, and reviewers must treat accidental escalation beyond those bounds as a merge blocker.
**Constitution alignment (OPS-UX):** Existing `OperationRun` behavior remains unchanged. The feature does not create or rename run types, change service-owned run transitions, or alter progress or notification contracts.
**Constitution alignment (RBAC-UX):** The feature affects workspace-context routes on `/admin`, tenant-context routes on `/admin/t/{external_id}`, and shared shell context display. Non-members remain deny-as-not-found. Members without required capability remain authorization failures only after workspace and tenant entitlement are established. Context resolution must never surface inaccessible workspace or tenant truth, and global search must remain tenant-safe under the resolved shell contract. Positive and negative authorization regression coverage must prove that shell cleanup does not relax these rules.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. The feature does not add synchronous authentication-handshake behavior.
**Constitution alignment (BADGE-001):** The feature does not introduce a new badge language. Existing status and severity badges remain centrally defined and must not be remapped ad hoc as part of the shell-context work.
**Constitution alignment (UI-FIL-001):** The feature must rely on existing panel shell surfaces, native Filament controls, and shared shell primitives already used by the repo. It must not introduce a new local status language or a second context widget family beside the shared shell contract.
**Constitution alignment (UI-NAMING-001):** Operator vocabulary must stay domain-first and consistent across shell labels, action labels, notifications, and recovery copy. Canonical terms include `Workspace`, `Tenant`, `No tenant selected`, `Switch workspace`, `Select tenant`, `Clear tenant context`, and `Context unavailable`.
**Constitution alignment (DECIDE-001):** The affected shell surfaces are secondary context surfaces. They exist to make the next operator decision trustworthy by showing current scope and offering explicit scope changes, not by becoming a separate workbench.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The shared shell must expose one primary context display and switch model, one recovery model, explicit placement of scope-reset actions, and one canonical vocabulary for workspace and tenant truth. It must avoid competing header, modal, or partial-owned context truths.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Context display, scope selection, and scope recovery must remain separated from destructive data actions and from unrelated page-local actions. Tenant clear remains a context-reset action grouped with tenant controls, not a destructive record action.
**Constitution alignment (OPSURF-001):** Default-visible shell content must stay operator-first: active workspace, tenant state, validity, and the next valid context action. Any diagnostic explanation of discarded requests or remembered candidates must stay secondary and only appear where necessary.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature replaces competing shell truth with one resolved context model and a bounded state vocabulary. It must not add redundant presenter or explanation layers, and tests must focus on visible outcomes such as displayed scope, redirects, clears, and invalid-state fallback.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied when the shared shell uses exactly one resolved context source for display, uses the same contract for switch, select, and clear flows, and avoids redundant alternate context widgets or empty grouped action placeholders. The UI Action Matrix below documents the shell surfaces.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes shell context behavior, not page form architecture. Shared shell surfaces must remain calm, concise, and explicit, and missing-context recovery must not overload the header with unrelated information.
### Global Context Taxonomy
- **Requested Context**: Workspace or tenant input requested by route, query, explicit switch/select action, or other documented entry path before validation.
- **Resolved Workspace Context**: The one validated workspace scope that the shell and workspace-bound pages consume.
- **Resolved Tenant Context**: The one validated tenant scope inside the resolved workspace, or the explicit tenantless state when no tenant is active.
- **Remembered Context**: Non-leading convenience state used only as a restore candidate after validation.
- **Invalid / Unavailable Context**: Requested or remembered workspace or tenant input that cannot be honored because it is missing, inaccessible, incompatible, or no longer valid.
- **Tenantless Workspace State**: A valid workspace-scoped state where no tenant is active and the shell must say so explicitly.
### Context Source Hierarchy
| Context facet | Leading source | Supporting sources | Never-leading sources |
|---|---|---|---|
| Active workspace | Valid requested workspace for the current entry flow, otherwise the validated current workspace state | Remembered last workspace only when there is no active session workspace and the restore rule explicitly allows it | View-local fallbacks, raw query hints outside documented flows, raw tenant-panel state |
| Active tenant | Valid tenant required by the current route or explicitly selected by the operator inside the resolved workspace | Query-backed tenant hints only on explicitly allowed workspace-scoped routes after validation, then remembered tenant for the resolved workspace only when no higher-priority tenant is active and the target surface allows restore | Tenant from a different workspace, stale remembered tenant, partial-local shell state, raw panel tenant state treated as an independent truth |
| Shell display | The resolved context model only | none | Any partial-owned inference, convenience hint, or stale page-local context that is not part of the resolved context |
### Context Source Inventory Ownership
- The canonical inventory of in-scope workspace and tenant context sources is maintained in `data-model.md` under `Context Source Inventory`.
- Any new source that can influence shell context must be added there with its source role, owning seam, and validation note before implementation or cleanup work lands.
### Context Flow Summary
| Flow | Trigger | Required validation | Allowed outcome | Forbidden outcome |
|---|---|---|---|---|
| Workspace switch | Explicit shell action | Workspace entitlement, route compatibility, tenant compatibility re-evaluation | Resolved target workspace with valid tenant or explicit tenantless state | Carrying an incompatible tenant silently into the new workspace |
| Tenant select | Explicit shell action or required tenant entry route | Tenant entitlement, membership in resolved workspace, route compatibility | Resolved tenant inside the active workspace | Tenant becoming active without a valid workspace or despite incompatibility |
| Tenant clear | Explicit shell action | Current route compatibility with tenantless state | Valid tenantless workspace state or redirect to workspace-level fallback | Remaining on a tenant-required route with no valid tenant |
| Restore | First load or eligible return flow | Restore rule, workspace compatibility, tenant compatibility, entitlement | Valid restored workspace or tenant context | Remembered context overriding an explicit current truth or reviving invalid scope |
| Invalid-context recovery | Any invalid request or stale remembered state | Missing, inaccessible, incompatible, or unauthorized determination | Clear fallback state and explicit recovery path | Hidden failure or stale shell scope display |
### Documented Workspace-Safe Fallbacks
| Situation | Required fallback |
|---|---|
| External previous URL, missing referrer, or clear-flow sentinel path | `/admin/operations` via `admin.operations.index` |
| Missing or unrecoverable workspace truth at shell entry or restore time | `/admin/choose-workspace` |
| Tenant-bound route under `/admin/t/{external_id}/...` or `/admin/tenants/...` with a valid current workspace | `admin.workspace.managed-tenants.index` for the current workspace |
| Tenant-bound route cleanup after workspace truth is no longer available | `/admin` via `admin.home` |
| Tenant-scoped evidence path under `/admin/evidence/...` except `/admin/evidence/overview` | `/admin/evidence/overview` via `admin.evidence.overview` |
| Workspace-scoped route that is tenantless-capable | Remain on the same route in explicit tenantless state |
| Canonical workspace record viewer under `/admin/operations/{run}` with valid entitlement | Remain on the same viewer route in explicit tenantless or tenant-scoped state, whichever the resolved contract allows |
### Explicit Page-Category Exceptions
- `/admin/choose-workspace` is the explicit `workspace_chooser_exception` route. It remains reachable without an established workspace and must not be inferred from generic missing-workspace behavior.
- Tenant-scoped evidence paths under `/admin/evidence/...` except `/admin/evidence/overview` are explicit `tenant_scoped_evidence` routes for recovery purposes and must fall back to `admin.evidence.overview`.
### Documented Workspace Switch Destinations
| Switch outcome | Destination |
|---|---|
| Safe intended return inside `/admin...` | Intended URL wins when still valid for the resolved workspace |
| Resolved workspace with zero selectable tenants | `admin.workspace.managed-tenants.index` |
| Resolved workspace with multiple selectable tenants | `/admin/choose-tenant` |
| Resolved workspace with exactly one selectable tenant | Tenant dashboard route under `/admin/t/{external_id}` |
### Assumptions and Dependencies
- The product remains workspace-first: workspace context is the primary shell scope and tenant context stays subordinate to it.
- Existing admin and tenant panels continue to share shell-level context surfaces rather than splitting into unrelated context systems.
- Existing entitlement checks for workspace membership and tenant membership remain the security boundary the shell contract must respect.
- Current-release workspace-independent exception coverage is limited to the workspace chooser flow; it must stay explicit and must not be inferred from a generic missing-workspace failure path.
- Page-level tab, filter, inspect, and draft/apply semantics remain governed by the separate monitoring page-state contract unless this spec explicitly takes ownership.
### Functional Requirements
- **FR-199-001 Source inventory**: The product MUST maintain one explicit inventory of all workspace and tenant context sources that can affect the shared shell contract, and that inventory MUST remain the canonical feature artifact for source roles and ownership.
- **FR-199-002 Single resolved truth**: The shell MUST expose exactly one resolved workspace truth and exactly one resolved tenant truth for each request.
- **FR-199-003 Workspace primacy**: Workspace MUST remain the primary shell scope for the product.
- **FR-199-004 Tenant dependency**: Tenant context MUST never remain active without a valid resolved workspace.
- **FR-199-005 Source-role declaration**: Every context source in scope MUST be classified as leading, supporting, or never-leading.
- **FR-199-006 Requested-context validation**: Requested context from route, query, or explicit switch/select flows MUST be validated before it becomes active shell context.
- **FR-199-007 Query-role discipline**: Query-based context hints MUST only participate when their role is explicitly defined by the contract.
- **FR-199-008 Remembered-context role**: Remembered or last-used values MUST remain supporting restore candidates only.
- **FR-199-009 Remembered-context precedence**: Remembered context MUST NEVER outrank a valid route request, explicit operator selection, or already resolved current-request truth.
- **FR-199-010 Workspace switch contract**: Workspace switching MUST define the resulting tenant outcome, route compatibility outcome, and fallback behavior.
- **FR-199-011 Tenant re-evaluation on workspace switch**: When workspace changes, existing tenant context MUST be re-evaluated against the target workspace before it may remain active.
- **FR-199-012 Tenant select contract**: Tenant selection MUST only activate a tenant inside the currently resolved workspace and only after the route's selector-operability check succeeds.
- **FR-199-013 Tenant clear contract**: Tenant clear MUST be an explicit operator flow with deterministic resulting scope and redirect behavior.
- **FR-199-014 Tenant-required fallback**: Clearing or losing tenant context on a tenant-required route MUST redirect to a documented workspace-level fallback.
- **FR-199-015 Tenantless validity**: Workspace-level pages may operate without an active tenant only when they are explicitly defined as tenantless-capable.
- **FR-199-016 Workspace-required validity**: Workspace-bound pages MUST NOT behave as valid without a resolved workspace.
- **FR-199-017 Workspace-independent exception discipline**: If a route is workspace-independent, that exception MUST be explicit and MUST NOT be inferred from missing context.
- **FR-199-018 Distinct invalid-state handling**: Missing workspace, missing tenant, invalid tenant, inaccessible tenant, and incompatible tenant MUST be distinguishable in contract behavior where they imply different operator recovery paths.
- **FR-199-019 Visible scope parity**: Any workspace or tenant scope shown in the shell MUST match the scope actually governing the current request.
- **FR-199-020 Context-bar derivation**: The context bar MUST derive its display and affordances only from resolved shell context and MUST NOT keep a second implicit context model.
- **FR-199-021 Shared entry-rule consistency**: Shell entry through direct navigation, route-bound tenant context, query-backed request context, switch flows, and restore flows MUST use the same resolution rules.
- **FR-199-022 Page-state separation**: Global shell context MUST remain distinct from local page-state such as tabs, filters, inspect state, and draft/apply behavior.
- **FR-199-023 Redirect consistency**: Redirect and return behavior after switch, clear, or invalid-context recovery MUST be deterministic and documented.
- **FR-199-024 Restore entry discipline**: Restore behavior MUST explicitly define which entry flows may consult remembered workspace and tenant values and when restore is skipped.
- **FR-199-025 Invalid remembered-state cleanup**: Invalid remembered workspace or tenant context MUST be discarded from support state cleanly and MUST NOT revive stale shell truth.
- **FR-199-026 Panel consistency**: Relevant admin and tenant panel shell surfaces that share the contract MUST resolve and display context by the same rules.
- **FR-199-027 Supporting-state boundaries**: Raw panel tenant state, session convenience data, and view-local shell logic MAY support resolution but MUST NOT become independent active truth.
- **FR-199-028 Workspace and tenant compatibility**: Tenant context MUST always remain compatible with the resolved workspace and the current route type.
- **FR-199-029 Explicit tenantless display**: When no tenant is active, the shell MUST show an explicit tenantless state rather than implying remembered or hidden tenant scope.
- **FR-199-030 Recovery visibility**: Invalid or missing context recovery MUST provide a clear next action and MUST not strand operators in an ambiguous half-context state.
- **FR-199-031 Global search safety**: Shell-context resolution MUST preserve tenant-safe and workspace-safe search behavior so inaccessible tenant-owned results never become visible through context drift.
- **FR-199-032 No partial-owned authority**: Partial-local rendering logic MAY format resolved context, but it MUST NOT own precedence, validation, or recovery decisions.
- **FR-199-033 Regression coverage**: Automated tests MUST cover context resolution, workspace switch, tenant select, tenant clear, restore behavior, invalid-context handling, and shell display consistency.
- **FR-199-034 Manual shell smoke checks**: Manual smoke checks MUST confirm that operators can understand scope, switch scope, clear tenant context, and recover from invalid context without hidden state.
- **FR-199-035 Closure documentation**: Final documentation MUST record the source hierarchy, allowed switch/select/clear/restore rules, fallback behavior, and what remains out of scope for page-state or constitution specs.
- **FR-199-036 No navigation rewrite**: The implementation MUST standardize shell context truth without turning this feature into a general navigation or information-architecture rewrite.
### Non-Goals
- Standardizing page-level tabs, filters, inspect state, or draft/apply semantics that belong to the page-state contract.
- Redesigning shared detail micro-UI families or generic header action patterns outside shell-context needs.
- Rewriting product information architecture beyond what is required to make shell context truthful.
- Creating a generic shell platform, universal context engine, or speculative cross-product abstraction.
- Treating tenant as a globally independent truth outside the workspace-first model.
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Global context bar / shell hook | Shared topbar shell surface on admin and tenant panels | `Switch workspace`, `Select tenant`, `Clear tenant context` | none | none | none | `Choose workspace` or `Select tenant` only when the contract requires recovery | none | n/a | no | Context actions change shell scope only. They must use one resolved context contract and must not introduce a second widget-owned truth. |
| Context recovery shell state | Shared shell recovery state | `Choose workspace`, `Return to workspace scope`, `Clear invalid tenant request` | none | none | none | Same recovery actions only | none | n/a | no | Recovery is a shell-state correction, not a record workflow. No destructive record action is introduced. |
### Key Entities *(include if feature involves data)*
- **Requested Context**: A requested workspace or tenant scope supplied by route, query, explicit switch/select action, or another documented entry path before validation.
- **Resolved Context**: The one validated shell truth consumed by the shared shell and the current page, composed of a resolved workspace and a resolved tenant or explicit tenantless state.
- **Remembered Context**: A non-leading convenience candidate representing last-used workspace or tenant values that may be considered for restore under documented rules.
- **Invalid / Unavailable Context**: Requested or remembered context that cannot be honored because it is missing, inaccessible, incompatible, or no longer valid.
- **Tenantless Workspace State**: A valid shell state in which a workspace is active but no tenant is active.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In validation scenarios, operators can identify the active workspace and tenant or tenantless state from the shared shell within 3 seconds on all in-scope entry paths.
- **SC-002**: 100% of documented workspace-switch, tenant-select, tenant-clear, and invalid-context scenarios land in one valid resolved scope that matches what the shell displays.
- **SC-003**: 100% of tested invalid, inaccessible, or incompatible requested and remembered context scenarios fall back without leaving stale workspace or tenant indicators behind.
- **SC-004**: Shared admin-panel and tenant-panel shell entry paths show the same resolved scope truth for the same entitled scenario during validation.
- **SC-005**: Manual smoke validation confirms that operators never need trial-and-error navigation to understand whether they are in workspace-only or tenant scope on the covered shell flows.

View File

@ -0,0 +1,297 @@
# Tasks: Global Context Shell Contract
**Input**: Design documents from `/specs/199-global-context-shell-contract/`
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Tests are REQUIRED for this feature because it changes runtime shell resolution, session-backed workspace and tenant context behavior, redirect and recovery rules, shared Filament shell rendering, and authorization-sensitive scope fallbacks in a Laravel/Pest codebase.
**Operations**: This feature does not create a new `OperationRun`, background workflow, or audit-only DB mutation path. The work is limited to request-scoped shell context resolution, redirects, and shared shell rendering.
**RBAC**: Existing workspace membership, tenant entitlement, and 404 vs 403 semantics remain authoritative. Tasks must preserve deny-as-not-found for non-members or non-entitled scope, keep capability failures server-side after scope is established, and keep global search tenant-safe under the canonical shell contract.
**Operator Surfaces**: The shared `context-bar` shell surface and the shell recovery state remain secondary context surfaces. Tasks must keep them operator-first, truthful, and free of competing widget-owned scope state.
**Filament UI Action Surfaces**: No new destructive actions, Resources, or alternate shell widgets are introduced. `Switch workspace`, `Select tenant`, `Clear tenant context`, and recovery actions remain the only in-scope operator actions.
**Filament UI UX-001**: No new create, edit, or view page layout work is introduced. The feature is limited to shared shell rendering, route behavior, and context recovery.
**Badges**: No new badge language or badge mapping is introduced.
**Organization**: Tasks are grouped by user story so each story can be implemented and verified as an independent increment.
## Test Governance Checklist
- Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
- New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
- Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
- Planned validation commands cover the change without pulling in unrelated lane cost.
- Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
## Phase 1: Setup (Shell Contract Regression Scaffolding)
**Purpose**: Create the focused regression files, source-inventory baseline, and verification baseline needed to implement Spec 199 safely.
- [X] T001 Create shell-contract regression scaffolding in `apps/platform/tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php`, `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`, and `apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php`
- [X] T002 [P] Create mutation-flow regression scaffolding in `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php` and extend `apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php`
- [X] T003 [P] Confirm lane assignment, source-inventory ownership, performance-proof commands, and timed manual smoke coverage in `specs/199-global-context-shell-contract/plan.md`, `specs/199-global-context-shell-contract/data-model.md`, and `specs/199-global-context-shell-contract/quickstart.md`
---
## Phase 2: Foundational (Blocking Canonical Resolver Seams)
**Purpose**: Put the canonical shell-resolution seams in place before any story-level behavior is changed.
**CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Implement canonical resolved shell-context precedence and recovery metadata in `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- [X] T005 [P] Align session-backed workspace, remembered-tenant, and safe intended-url helpers with restore-only semantics in `apps/platform/app/Support/Workspaces/WorkspaceContext.php` and `apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php`
- [X] T006 [P] Route admin-panel tenant consumption through the canonical shell contract in `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`
- [X] T007 Update unit coverage for route-first, Filament-tenant, remembered-tenant, tenantless, and invalid remembered-context branches in `apps/platform/tests/Unit/Support/OperateHub/OperateHubShellResolutionTest.php` and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
**Checkpoint**: The shared shell resolver, storage semantics, and panel-consumption seam exist, so story work can proceed independently.
---
## Phase 3: User Story 1 - See The True Current Scope (Priority: P1)
**Goal**: Make every shared shell surface display the same truthful workspace and tenant state the request is actually using.
**Independent Test**: Open workspace-scoped and tenant-bound entry paths with tenant-scoped and tenantless states, then verify the shared shell displays the same resolved truth the page is operating under.
### Tests for User Story 1
- [X] T008 [P] [US1] Extend shared-shell truth display and no-hidden-page-state coverage for tenant-scoped and tenantless routes in `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php` and `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`
- [X] T009 [P] [US1] Add recovery-shell display assertions for missing workspace, missing tenant, and explicit tenantless states in `apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php`
### Implementation for User Story 1
- [X] T010 [US1] Reduce the shared shell to a consumer-only resolved-context display and keep page-local filters, tabs, and inspect state out of the shell contract in `apps/platform/resources/views/filament/partials/context-bar.blade.php`
- [X] T011 [US1] Keep both panels rendering the same shared shell contract in `apps/platform/app/Providers/Filament/AdminPanelProvider.php` and `apps/platform/app/Providers/Filament/TenantPanelProvider.php`
- [X] T012 [US1] Run focused US1 verification against `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php`, and `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`
**Checkpoint**: Shared shell surfaces now show one truthful scope model instead of competing display logic.
---
## Phase 4: User Story 2 - Switch Workspace Without Stale Tenant Truth (Priority: P1)
**Goal**: Make workspace switching deterministically re-evaluate tenant compatibility, fallback, and redirect behavior.
**Independent Test**: Start from a valid workspace and tenant, switch to compatible and incompatible target workspaces, and verify the resulting tenant state, redirect destination, and authorization behavior.
### Tests for User Story 2
- [X] T013 [P] [US2] Add switch regression coverage for compatible, incompatible, archived, and non-member target workspaces in `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php`, and `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php`
- [X] T014 [P] [US2] Extend positive and negative workspace-switch affordance coverage in `apps/platform/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php` and `apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php`
### Implementation for User Story 2
- [X] T015 [US2] Make workspace switching re-evaluate tenant compatibility and clear incompatible tenant state in `apps/platform/app/Http/Controllers/SwitchWorkspaceController.php` and `apps/platform/app/Support/Workspaces/WorkspaceContext.php`
- [X] T016 [US2] Canonicalize post-switch destination rules and safe intended-url consumption in `apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php` and `apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php`
- [X] T017 [US2] Run focused US2 verification against `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php`, `apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php`, and `apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php`
**Checkpoint**: Workspace switching can no longer carry stale tenant truth into the next workspace or route.
---
## Phase 5: User Story 3 - Select Or Clear Tenant Intentionally (Priority: P1)
**Goal**: Make explicit tenant selection and tenant clear flows behave like deterministic scope decisions instead of partial-local heuristics.
**Independent Test**: Select a tenant from the shared shell, clear tenant context from a workspace page, and clear it from a tenant-bound route to verify predictable scope and redirect outcomes.
### Tests for User Story 3
- [X] T018 [P] [US3] Extend explicit tenant-selection coverage for happy-path, non-operable, wrong-workspace, and unauthorized tenant requests in `apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php` and `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`
- [X] T019 [P] [US3] Extend clear-tenant route-compatibility coverage for workspace-scoped, tenant-bound, tenant-scoped evidence, and canonical workspace record viewer pages in `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, and `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`
### Implementation for User Story 3
- [X] T020 [US3] Align explicit tenant selection with the canonical shell contract, selector-operability rules, and remembered-context rules in `apps/platform/app/Http/Controllers/SelectTenantController.php` and `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- [X] T021 [US3] Standardize clear-tenant recovery outcomes (same-route tenantless workspace state, `admin.operations.index`, `admin.evidence.overview`, `admin.workspace.managed-tenants.index`, `admin.operations.view`, `admin.home`) and route compatibility in `apps/platform/app/Http/Controllers/ClearTenantContextController.php` and `apps/platform/app/Support/Tenants/TenantPageCategory.php`
- [X] T022 [US3] Keep shell action labels and tenantless wording aligned to the approved vocabulary in `apps/platform/resources/views/filament/partials/context-bar.blade.php`
- [X] T023 [US3] Run focused US3 verification against `apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php`, `apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, and `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`
**Checkpoint**: Tenant selection and clear behavior now act as explicit scope changes with stable wording and recovery.
---
## Phase 6: User Story 4 - Reject Invalid Or Stale Context Cleanly (Priority: P1)
**Goal**: Make invalid route, query, and remembered context fail cleanly without leaving stale scope visible or widening access.
**Independent Test**: Enter the shell with invalid route, query-hint, and remembered context combinations, then verify the request falls back to a valid scope or 404 path with no stale shell truth left behind.
### Tests for User Story 4
- [X] T024 [P] [US4] Add valid and invalid query-hint coverage plus stale remembered-context coverage in `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php` and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
- [X] T025 [P] [US4] Extend tenant-required fallback, workspace-required recovery, and explicit chooser-route exception coverage in `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, and `apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`
### Implementation for User Story 4
- [X] T026 [US4] Replace ad hoc tenant-selection heuristics with canonical invalid-context checks in `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`
- [X] T027 [US4] Tighten page-category classification and invalid-context fallback mapping, including the explicit workspace-independent chooser-route exception, in `apps/platform/app/Support/Tenants/TenantPageCategory.php` and `apps/platform/app/Support/OperateHub/OperateHubShell.php`
- [X] T028 [US4] Preserve deny-as-not-found, forbidden, and no-stale-scope recovery semantics across `/admin` and `/admin/t/{external_id}` in `apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php`, `apps/platform/app/Http/Controllers/ClearTenantContextController.php`, and `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`
- [X] T029 [US4] Run focused US4 verification against `apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php`, `apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php`, `apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php`, and `apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php`
**Checkpoint**: Invalid or stale context now recovers explicitly and never survives as a false active scope.
---
## Phase 7: User Story 5 - Keep Shared Shell Logic Consistent Across Panels (Priority: P2)
**Goal**: Keep admin and tenant panel entry paths, supporting panel state, and global search safety aligned to the same shell contract.
**Independent Test**: Resolve the same entitled workspace and tenant through admin and tenant panel entry paths, then verify both panels show the same active truth and preserve tenant-safe search behavior.
### Tests for User Story 5
- [X] T030 [P] [US5] Add admin-versus-tenant panel parity coverage for the same entitled workspace and tenant scenario in `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php` and `apps/platform/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php`
- [X] T031 [P] [US5] Extend global-search context-safety coverage so tenant-owned results stay scoped under the canonical shell contract in `apps/platform/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`, and `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
### Implementation for User Story 5
- [X] T032 [US5] Keep panel-specific context sources subordinate to the canonical shell contract in `apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php`, `apps/platform/app/Providers/Filament/AdminPanelProvider.php`, and `apps/platform/app/Providers/Filament/TenantPanelProvider.php`
- [X] T033 [US5] Preserve tenant-safe global search scoping while the shell contract is consolidated in `apps/platform/app/Filament/Concerns/ScopesGlobalSearchToTenant.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, and `apps/platform/app/Filament/Resources/PolicyResource.php`
- [X] T034 [US5] Run focused US5 verification against `apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php`, `apps/platform/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php`, `apps/platform/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php`
**Checkpoint**: Shared shell logic, panel state, and search safety remain aligned across admin and tenant entry paths.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Finish validation, documentation parity, non-functional render proof, and operator smoke coverage across all stories.
- [X] T035 [P] Reconcile final source inventory, source hierarchy, recovery vocabulary, fallback matrix, and verification commands in `specs/199-global-context-shell-contract/plan.md`, `specs/199-global-context-shell-contract/research.md`, `specs/199-global-context-shell-contract/data-model.md`, `specs/199-global-context-shell-contract/contracts/global-context-shell.logical.openapi.yaml`, and `specs/199-global-context-shell-contract/quickstart.md`
- [X] T036 [P] Run the focused Pest validation pack from `specs/199-global-context-shell-contract/quickstart.md`, including DB-only render and no-enqueue shell proof
- [X] T037 Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [X] T038 [P] Execute the timed 3-second manual smoke checklist from `specs/199-global-context-shell-contract/quickstart.md` for tenantless entry, workspace switch, tenant select, tenant clear, evidence fallback, canonical workspace record viewer fallback, invalid remembered tenant, explicit chooser-route exception handling, and panel parity
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: Starts immediately and creates the focused regression scaffolding and verification baseline.
- **Foundational (Phase 2)**: Depends on Setup and blocks all story work until the canonical resolver seams are in place.
- **User Stories (Phase 3+)**: All depend on Foundational completion.
- **Polish (Phase 8)**: Depends on the desired user stories being complete.
### User Story Dependencies
- **US1**: Depends only on the foundational resolver seam and is the recommended MVP slice.
- **US2**: Depends on the foundational seam and can proceed independently of US1 once canonical workspace and tenant precedence exist.
- **US3**: Depends on the foundational seam and can proceed independently of US1 and US2, though it benefits from the shared shell display already being consumer-only.
- **US4**: Depends on the foundational seam and should land after the invalid-context matrix is stable, but it does not require US2 or US3 to be complete.
- **US5**: Depends on the foundational seam and benefits from at least one earlier story landing first so panel parity and search safety are verified against the implemented contract.
### Within Each User Story
- Story tests should be written before or alongside implementation and should fail before the story is considered complete.
- Resolver and storage seam updates must land before controller, middleware, or shell display changes are considered finished.
- Authorization-sensitive regressions must stay in Unit or Feature lanes only; no browser family should be added for this feature.
- Each story-level verification task should run after the story's implementation tasks are complete.
### Parallel Opportunities
- `T001`, `T002`, and `T003` can run in parallel during Setup.
- `T005` and `T006` can run in parallel during Foundational work.
- `T008` and `T009` can run in parallel for User Story 1.
- `T013` and `T014` can run in parallel for User Story 2.
- `T018` and `T019` can run in parallel for User Story 3.
- `T024` and `T025` can run in parallel for User Story 4.
- `T030` and `T031` can run in parallel for User Story 5.
- `T035`, `T036`, and `T038` can run in parallel after implementation is complete.
---
## Parallel Example: User Story 1
```bash
# User Story 1 tests in parallel:
Task: "T008 Extend shared-shell truth display and no-hidden-page-state coverage in apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php and apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php"
Task: "T009 Add recovery-shell display assertions in apps/platform/tests/Feature/Filament/WorkspaceContextRecoveryDisplayTest.php"
# Then land the shared shell implementation:
Task: "T010 Reduce the shared shell to a consumer-only resolved-context display and keep page-local filters, tabs, and inspect state out of the shell contract in apps/platform/resources/views/filament/partials/context-bar.blade.php"
Task: "T011 Keep both panels rendering the same shared shell contract in apps/platform/app/Providers/Filament/AdminPanelProvider.php and apps/platform/app/Providers/Filament/TenantPanelProvider.php"
```
## Parallel Example: User Story 2
```bash
# User Story 2 tests in parallel:
Task: "T013 Add switch regression coverage in apps/platform/tests/Feature/Workspaces/SwitchWorkspaceControllerTest.php, apps/platform/tests/Feature/Workspaces/WorkspaceRedirectResolverTest.php, and apps/platform/tests/Feature/Workspaces/SwitchWorkspaceRedirectsToTenantRegistrationWhenNoTenantsTest.php"
Task: "T014 Extend workspace-switch affordance coverage in apps/platform/tests/Feature/Workspaces/WorkspaceSwitchUserMenuTest.php and apps/platform/tests/Feature/Workspaces/ChooseWorkspaceRedirectsToChooseTenantTest.php"
# Then land controller and redirect behavior:
Task: "T015 Make workspace switching re-evaluate tenant compatibility in apps/platform/app/Http/Controllers/SwitchWorkspaceController.php and apps/platform/app/Support/Workspaces/WorkspaceContext.php"
Task: "T016 Canonicalize post-switch destination rules in apps/platform/app/Support/Workspaces/WorkspaceRedirectResolver.php and apps/platform/app/Support/Workspaces/WorkspaceIntendedUrl.php"
```
## Parallel Example: User Story 3
```bash
# User Story 3 tests in parallel:
Task: "T018 Extend explicit tenant-selection coverage in apps/platform/tests/Feature/Workspaces/SelectTenantControllerTest.php and apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php"
Task: "T019 Extend clear-tenant route-compatibility coverage in apps/platform/tests/Feature/Spec085/OperationsIndexHeaderTest.php and apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php"
# Then land explicit scope-mutation behavior:
Task: "T020 Align explicit tenant selection with the canonical shell contract in apps/platform/app/Http/Controllers/SelectTenantController.php and apps/platform/app/Support/OperateHub/OperateHubShell.php"
Task: "T021 Standardize clear-tenant recovery destinations in apps/platform/app/Http/Controllers/ClearTenantContextController.php and apps/platform/app/Support/Tenants/TenantPageCategory.php"
```
## Parallel Example: User Story 4
```bash
# User Story 4 tests in parallel:
Task: "T024 Add invalid route, query-hint, and stale remembered-context coverage in apps/platform/tests/Feature/Workspaces/GlobalContextShellContractTest.php and apps/platform/tests/Unit/Support/Workspaces/WorkspaceContextRememberedTenantTest.php"
Task: "T025 Extend tenant-required fallback, workspace-required recovery, and explicit chooser-route exception coverage in apps/platform/tests/Feature/Workspaces/ChooseTenantPageTest.php, apps/platform/tests/Feature/Workspaces/ChooseWorkspacePageTest.php, and apps/platform/tests/Feature/Workspaces/EnsureWorkspaceSelectedMiddlewareTest.php"
# Then land middleware and fallback behavior:
Task: "T026 Replace ad hoc tenant-selection heuristics in apps/platform/app/Support/Middleware/EnsureFilamentTenantSelected.php"
Task: "T027 Tighten page-category classification and invalid-context fallback mapping, including the explicit workspace-independent chooser-route exception, in apps/platform/app/Support/Tenants/TenantPageCategory.php and apps/platform/app/Support/OperateHub/OperateHubShell.php"
```
## Parallel Example: User Story 5
```bash
# User Story 5 tests in parallel:
Task: "T030 Add admin-versus-tenant panel parity coverage in apps/platform/tests/Feature/Filament/WorkspaceContextTopbarAndTenantSelectionTest.php and apps/platform/tests/Feature/Workspaces/WorkspacesResourceIsTenantlessTest.php"
Task: "T031 Extend global-search context-safety coverage in apps/platform/tests/Feature/Rbac/AdminGlobalSearchContextSafetyTest.php, apps/platform/tests/Feature/TenantRBAC/TenantSwitcherScopeTest.php, and apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php"
# Then land panel-parity and search-scope behavior:
Task: "T032 Keep panel-specific context sources subordinate to the canonical shell contract in apps/platform/app/Filament/Concerns/ResolvesPanelTenantContext.php, apps/platform/app/Providers/Filament/AdminPanelProvider.php, and apps/platform/app/Providers/Filament/TenantPanelProvider.php"
Task: "T033 Preserve tenant-safe global search scoping in apps/platform/app/Filament/Concerns/ScopesGlobalSearchToTenant.php, apps/platform/app/Filament/Resources/TenantResource.php, and apps/platform/app/Filament/Resources/PolicyResource.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate that the shared shell shows one truthful tenant-scoped and tenantless model before moving on.
### Incremental Delivery
1. Establish the canonical shell resolver and storage semantics.
2. Deliver truthful shared-shell display as the MVP.
3. Add deterministic workspace switching.
4. Add deterministic tenant select and clear flows.
5. Harden invalid-context recovery.
6. Close with cross-panel parity, search safety, and final validation.
### Parallel Team Strategy
1. One developer can land Setup plus Foundational resolver seams.
2. After Foundational work is complete, one developer can take US1 or US2 while another works on US3 or US4 because the primary file overlap is limited.
3. US5 should land after at least one earlier story so panel parity and global-search safety verify the real implemented contract.
---
## Notes
- `[P]` tasks are limited to work on different files or isolated test files with no incomplete dependency overlap.
- `[US1]` through `[US5]` map directly to the user stories in `spec.md`.
- The suggested MVP scope is Phase 1 through Phase 3 only.
- This task list preserves Filament v5 and Livewire v4 compliance, keeps provider registration unchanged in `bootstrap/providers.php`, keeps destructive-action rules unchanged because no destructive record action is introduced, and preserves existing tenant-safe global search behavior while the shell contract is consolidated.

View File

@ -0,0 +1,39 @@
# Specification Quality Checklist: Test Runtime Trend Reporting & Baseline Recalibration
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-17
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation run: 2026-04-17
- No template placeholders or [NEEDS CLARIFICATION] markers remain.
- The spec stays repository-governance-focused: it defines trend visibility, drift semantics, recalibration policy, and contributor behavior without prescribing language-, framework-, or API-level implementation.
- Repository-specific nouns such as lane, baseline, budget, hotspot, and summary are treated as domain requirements for the test-governance contract rather than low-level implementation detail.
- The scope remains intentionally narrow: it extends the governed lane system from Specs 206 through 210 with historical observability instead of inventing a broader analytics platform.
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`.

View File

@ -0,0 +1,540 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantatlas.local/specs/211-runtime-trend-recalibration/contracts/test-runtime-trend-history.schema.json",
"title": "LaneTrendHistoryArtifact",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"laneId",
"workflowProfile",
"generatedAt",
"policy",
"history",
"currentAssessment"
],
"properties": {
"schemaVersion": {
"type": "string",
"const": "1.0.0"
},
"laneId": {
"type": "string",
"enum": [
"fast-feedback",
"confidence",
"heavy-governance",
"browser",
"junit",
"profiling"
]
},
"workflowProfile": {
"type": "string"
},
"generatedAt": {
"type": "string",
"format": "date-time"
},
"policy": {
"$ref": "#/$defs/policy"
},
"history": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/historyRecord"
}
},
"currentAssessment": {
"$ref": "#/$defs/assessment"
},
"hotspotSnapshot": {
"$ref": "#/$defs/hotspotSnapshot"
},
"recalibrationDecisions": {
"type": "array",
"items": {
"$ref": "#/$defs/recalibrationDecision"
},
"default": []
},
"warnings": {
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"$defs": {
"policy": {
"type": "object",
"additionalProperties": false,
"required": [
"retentionLimit",
"comparisonWindowSize",
"minimumComparableSamples",
"varianceFloorSeconds",
"nearBudgetHeadroomSeconds",
"hotspotFamilyLimit",
"hotspotFileLimit",
"slowestEntryRetention"
],
"properties": {
"retentionLimit": {
"type": "integer",
"minimum": 1
},
"comparisonWindowSize": {
"type": "integer",
"minimum": 1
},
"minimumComparableSamples": {
"type": "integer",
"minimum": 3
},
"varianceFloorSeconds": {
"type": "integer",
"minimum": 0
},
"nearBudgetHeadroomSeconds": {
"type": "integer",
"minimum": 0
},
"hotspotFamilyLimit": {
"type": "integer",
"minimum": 1
},
"hotspotFileLimit": {
"type": "integer",
"minimum": 1
},
"slowestEntryRetention": {
"type": "integer",
"minimum": 1
},
"recalibrationPolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"baselineRequiresExplicitReview": {
"type": "boolean"
},
"budgetRequiresExplicitReview": {
"type": "boolean"
},
"minimumBudgetEvidenceSamples": {
"type": "integer",
"minimum": 1
}
}
}
}
},
"historyRecord": {
"type": "object",
"additionalProperties": false,
"required": [
"runRef",
"laneId",
"workflowId",
"triggerClass",
"generatedAt",
"wallClockSeconds",
"budgetSeconds",
"budgetStatus",
"blockingStatus",
"comparisonFingerprint"
],
"properties": {
"runRef": {
"type": "string"
},
"laneId": {
"type": "string"
},
"workflowId": {
"type": "string"
},
"triggerClass": {
"type": "string",
"enum": [
"pull-request",
"mainline-push",
"manual",
"scheduled",
"local"
]
},
"generatedAt": {
"type": "string",
"format": "date-time"
},
"wallClockSeconds": {
"type": "number",
"minimum": 0
},
"baselineSeconds": {
"type": [
"number",
"null"
],
"minimum": 0
},
"baselineSource": {
"type": [
"string",
"null"
]
},
"budgetSeconds": {
"type": "number",
"minimum": 0
},
"budgetStatus": {
"type": "string"
},
"blockingStatus": {
"type": "string"
},
"comparisonFingerprint": {
"type": "string"
},
"classificationTotals": {
"type": "array",
"items": {
"$ref": "#/$defs/runtimeBucket"
},
"default": []
},
"familyTotals": {
"type": "array",
"items": {
"$ref": "#/$defs/runtimeBucket"
},
"default": []
},
"hotspotFiles": {
"type": "array",
"items": {
"$ref": "#/$defs/runtimeBucket"
},
"default": []
},
"slowestEntries": {
"type": "array",
"items": {
"$ref": "#/$defs/slowestEntry"
},
"default": []
},
"artifactRefs": {
"type": "object",
"additionalProperties": false,
"properties": {
"summary": {
"type": "string"
},
"report": {
"type": "string"
},
"budget": {
"type": "string"
},
"junit": {
"type": "string"
},
"trendHistory": {
"type": "string"
}
}
}
}
},
"assessment": {
"type": "object",
"additionalProperties": false,
"required": [
"healthClass",
"recalibrationRecommendation",
"budgetHeadroomSeconds",
"summaryLine",
"windowStatus",
"sampleCount"
],
"properties": {
"healthClass": {
"type": "string",
"enum": [
"healthy",
"budget-near",
"trending-worse",
"regressed",
"unstable"
]
},
"recalibrationRecommendation": {
"type": "string",
"enum": [
"none",
"investigate",
"review-baseline",
"review-budget"
]
},
"budgetHeadroomSeconds": {
"type": "number"
},
"deltaToPreviousSeconds": {
"type": [
"number",
"null"
]
},
"deltaToPreviousPercent": {
"type": [
"number",
"null"
]
},
"deltaToBaselineSeconds": {
"type": [
"number",
"null"
]
},
"deltaToBaselinePercent": {
"type": [
"number",
"null"
]
},
"worseningStreak": {
"type": "integer",
"minimum": 0
},
"varianceObservedSeconds": {
"type": "number",
"minimum": 0
},
"windowStatus": {
"type": "string",
"enum": [
"stable",
"insufficient-history",
"scope-changed",
"noisy"
]
},
"sampleCount": {
"type": "integer",
"minimum": 0
},
"previousComparableRunRef": {
"type": [
"string",
"null"
]
},
"summaryLine": {
"type": "string"
}
}
},
"hotspotSnapshot": {
"type": "object",
"additionalProperties": false,
"required": [
"evidenceAvailability",
"familyDeltas",
"fileHotspots"
],
"properties": {
"evidenceAvailability": {
"type": "string",
"enum": [
"available",
"unavailable"
]
},
"familyDeltas": {
"type": "array",
"items": {
"$ref": "#/$defs/deltaBucket"
}
},
"fileHotspots": {
"type": "array",
"items": {
"$ref": "#/$defs/deltaBucket"
}
},
"newEntrants": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"droppedEntrants": {
"type": "array",
"items": {
"type": "string"
},
"default": []
}
}
},
"recalibrationDecision": {
"type": "object",
"additionalProperties": false,
"required": [
"targetType",
"decisionStatus",
"evidenceRunRefs",
"previousValueSeconds",
"rationaleCode",
"recordedIn",
"notes"
],
"properties": {
"targetType": {
"type": "string",
"enum": [
"baseline",
"budget"
]
},
"decisionStatus": {
"type": "string",
"enum": [
"candidate",
"approved",
"rejected"
]
},
"evidenceRunRefs": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"previousValueSeconds": {
"type": "number",
"minimum": 0
},
"proposedValueSeconds": {
"type": [
"number",
"null"
],
"minimum": 0
},
"rationaleCode": {
"type": "string",
"enum": [
"lane-scope-change",
"infrastructure-shift",
"post-improvement-reset",
"sustained-erosion",
"noise-rejected",
"manual-hold"
]
},
"recordedIn": {
"type": "string",
"description": "Active spec path or implementation PR reference for the approved or rejected recalibration decision."
},
"notes": {
"type": "string"
}
}
},
"runtimeBucket": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"runtimeSeconds"
],
"properties": {
"name": {
"type": "string"
},
"runtimeSeconds": {
"type": "number",
"minimum": 0
}
}
},
"slowestEntry": {
"type": "object",
"additionalProperties": false,
"required": [
"label",
"runtimeSeconds"
],
"properties": {
"label": {
"type": "string"
},
"runtimeSeconds": {
"type": "number",
"minimum": 0
},
"file": {
"type": [
"string",
"null"
]
}
}
},
"deltaBucket": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"currentSeconds",
"previousSeconds",
"deltaSeconds"
],
"properties": {
"name": {
"type": "string"
},
"currentSeconds": {
"type": "number",
"minimum": 0
},
"previousSeconds": {
"type": "number",
"minimum": 0
},
"deltaSeconds": {
"type": "number"
},
"deltaPercent": {
"type": [
"number",
"null"
]
},
"direction": {
"type": [
"string",
"null"
],
"enum": [
"up",
"down",
"flat",
null
]
}
}
}
}
}

View File

@ -0,0 +1,641 @@
openapi: 3.1.0
info:
title: Test Runtime Trend Reporting & Baseline Recalibration
version: 1.0.0
description: |
Logical contract for the repository-owned workflow that updates bounded lane
history, evaluates drift status, emits hotspot deltas, and records explicit
recalibration evidence. This file documents wrapper/support-class semantics,
not a public HTTP API.
servers:
- url: https://tenantatlas.local/logical
paths:
/test-governance/lanes/{laneId}/trend-history:
post:
summary: Update one lane's bounded trend history artifact
operationId: updateLaneTrendHistory
parameters:
- $ref: '#/components/parameters/LaneId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TrendHistoryUpdateRequest'
responses:
'200':
description: Updated bounded history artifact for the lane
content:
application/json:
schema:
$ref: '#/components/schemas/LaneTrendHistoryArtifact'
/test-governance/lanes/{laneId}/trend-assessment:
post:
summary: Evaluate drift status and hotspot deltas for one lane
operationId: evaluateLaneTrendAssessment
parameters:
- $ref: '#/components/parameters/LaneId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LaneTrendAssessmentRequest'
responses:
'200':
description: Current lane assessment including health class and hotspot snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/LaneTrendAssessmentResponse'
/test-governance/lanes/{laneId}/recalibration:
post:
summary: Evaluate or record an explicit recalibration decision for one lane
operationId: evaluateLaneRecalibration
parameters:
- $ref: '#/components/parameters/LaneId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RecalibrationEvaluationRequest'
responses:
'200':
description: Structured recalibration decision or candidate record
content:
application/json:
schema:
$ref: '#/components/schemas/RecalibrationDecision'
/test-governance/cycles/{cycleId}/summary:
get:
summary: Read the trend-aware summary for one reporting cycle
operationId: getTrendSummaryCycle
parameters:
- name: cycleId
in: path
required: true
schema:
type: string
responses:
'200':
description: Trend-aware cycle summary spanning the relevant lanes
content:
application/json:
schema:
$ref: '#/components/schemas/TrendSummaryCycle'
components:
parameters:
LaneId:
name: laneId
in: path
required: true
schema:
type: string
enum:
- fast-feedback
- confidence
- heavy-governance
- browser
- junit
- profiling
schemas:
TrendHistoryUpdateRequest:
type: object
additionalProperties: false
required:
- currentRecord
properties:
currentRecord:
$ref: '#/components/schemas/TrendRecord'
priorHistory:
type: array
items:
$ref: '#/components/schemas/TrendRecord'
description: |
Previously retained history records, typically hydrated from the
most recent comparable uploaded artifact bundle.
policyOverride:
$ref: '#/components/schemas/TrendPolicy'
LaneTrendAssessmentRequest:
type: object
additionalProperties: false
required:
- policy
- history
properties:
policy:
$ref: '#/components/schemas/TrendPolicy'
history:
type: array
items:
$ref: '#/components/schemas/TrendRecord'
includeHotspots:
type: boolean
default: true
LaneTrendAssessmentResponse:
type: object
additionalProperties: false
required:
- assessment
properties:
assessment:
$ref: '#/components/schemas/DriftAssessment'
hotspotSnapshot:
$ref: '#/components/schemas/HotspotSnapshot'
warnings:
type: array
items:
type: string
RecalibrationEvaluationRequest:
type: object
additionalProperties: false
required:
- targetType
- assessment
- evidenceRunRefs
properties:
targetType:
type: string
enum:
- baseline
- budget
assessment:
$ref: '#/components/schemas/DriftAssessment'
evidenceRunRefs:
type: array
minItems: 1
items:
type: string
proposedValueSeconds:
type:
- number
- 'null'
rationaleCode:
type:
- string
- 'null'
enum:
- lane-scope-change
- infrastructure-shift
- post-improvement-reset
- sustained-erosion
- noise-rejected
- manual-hold
- null
recordLocation:
type:
- string
- 'null'
description: Active spec path or implementation PR reference for the human-reviewed decision record.
LaneTrendHistoryArtifact:
type: object
additionalProperties: false
required:
- schemaVersion
- laneId
- workflowProfile
- generatedAt
- policy
- history
- currentAssessment
properties:
schemaVersion:
type: string
laneId:
type: string
workflowProfile:
type: string
generatedAt:
type: string
format: date-time
policy:
$ref: '#/components/schemas/TrendPolicy'
history:
type: array
items:
$ref: '#/components/schemas/TrendRecord'
currentAssessment:
$ref: '#/components/schemas/DriftAssessment'
hotspotSnapshot:
$ref: '#/components/schemas/HotspotSnapshot'
recalibrationDecisions:
type: array
items:
$ref: '#/components/schemas/RecalibrationDecision'
warnings:
type: array
items:
type: string
TrendPolicy:
type: object
additionalProperties: false
required:
- retentionLimit
- comparisonWindowSize
- minimumComparableSamples
- varianceFloorSeconds
- nearBudgetHeadroomSeconds
- hotspotFamilyLimit
- hotspotFileLimit
- slowestEntryRetention
properties:
retentionLimit:
type: integer
minimum: 1
comparisonWindowSize:
type: integer
minimum: 1
minimumComparableSamples:
type: integer
minimum: 3
varianceFloorSeconds:
type: integer
minimum: 0
nearBudgetHeadroomSeconds:
type: integer
minimum: 0
hotspotFamilyLimit:
type: integer
minimum: 1
hotspotFileLimit:
type: integer
minimum: 1
slowestEntryRetention:
type: integer
minimum: 1
recalibrationPolicy:
type: object
additionalProperties: false
properties:
baselineRequiresExplicitReview:
type: boolean
budgetRequiresExplicitReview:
type: boolean
minimumBudgetEvidenceSamples:
type: integer
minimum: 1
TrendRecord:
type: object
additionalProperties: false
required:
- runRef
- laneId
- workflowId
- triggerClass
- generatedAt
- wallClockSeconds
- budgetSeconds
- budgetStatus
- blockingStatus
- comparisonFingerprint
properties:
runRef:
type: string
laneId:
type: string
workflowId:
type: string
triggerClass:
type: string
enum:
- pull-request
- mainline-push
- manual
- scheduled
- local
generatedAt:
type: string
format: date-time
wallClockSeconds:
type: number
minimum: 0
baselineSeconds:
type:
- number
- 'null'
baselineSource:
type:
- string
- 'null'
budgetSeconds:
type: number
minimum: 0
budgetStatus:
type: string
blockingStatus:
type: string
comparisonFingerprint:
type: string
classificationTotals:
type: array
items:
$ref: '#/components/schemas/RuntimeBucket'
familyTotals:
type: array
items:
$ref: '#/components/schemas/RuntimeBucket'
hotspotFiles:
type: array
items:
$ref: '#/components/schemas/RuntimeBucket'
slowestEntries:
type: array
items:
$ref: '#/components/schemas/SlowestEntry'
artifactRefs:
type: object
additionalProperties: false
properties:
summary:
type: string
report:
type: string
budget:
type: string
junit:
type: string
trendHistory:
type: string
DriftAssessment:
type: object
additionalProperties: false
required:
- healthClass
- recalibrationRecommendation
- budgetHeadroomSeconds
- summaryLine
- windowStatus
- sampleCount
properties:
healthClass:
type: string
enum:
- healthy
- budget-near
- trending-worse
- regressed
- unstable
recalibrationRecommendation:
type: string
enum:
- none
- investigate
- review-baseline
- review-budget
budgetHeadroomSeconds:
type: number
deltaToPreviousSeconds:
type:
- number
- 'null'
deltaToPreviousPercent:
type:
- number
- 'null'
deltaToBaselineSeconds:
type:
- number
- 'null'
deltaToBaselinePercent:
type:
- number
- 'null'
worseningStreak:
type: integer
minimum: 0
varianceObservedSeconds:
type: number
minimum: 0
windowStatus:
type: string
enum:
- stable
- insufficient-history
- scope-changed
- noisy
sampleCount:
type: integer
minimum: 0
previousComparableRunRef:
type:
- string
- 'null'
summaryLine:
type: string
HotspotSnapshot:
type: object
additionalProperties: false
required:
- evidenceAvailability
- familyDeltas
- fileHotspots
properties:
evidenceAvailability:
type: string
enum:
- available
- unavailable
familyDeltas:
type: array
items:
$ref: '#/components/schemas/DeltaBucket'
fileHotspots:
type: array
items:
$ref: '#/components/schemas/DeltaBucket'
newEntrants:
type: array
items:
type: string
droppedEntrants:
type: array
items:
type: string
DeltaBucket:
type: object
additionalProperties: false
required:
- name
- currentSeconds
- previousSeconds
- deltaSeconds
properties:
name:
type: string
currentSeconds:
type: number
minimum: 0
previousSeconds:
type: number
minimum: 0
deltaSeconds:
type: number
deltaPercent:
type:
- number
- 'null'
direction:
type:
- string
- 'null'
enum:
- up
- down
- flat
- null
RuntimeBucket:
type: object
additionalProperties: false
required:
- name
- runtimeSeconds
properties:
name:
type: string
runtimeSeconds:
type: number
minimum: 0
SlowestEntry:
type: object
additionalProperties: false
required:
- label
- runtimeSeconds
properties:
label:
type: string
runtimeSeconds:
type: number
minimum: 0
file:
type:
- string
- 'null'
RecalibrationDecision:
type: object
additionalProperties: false
required:
- targetType
- decisionStatus
- evidenceRunRefs
- previousValueSeconds
- rationaleCode
- recordedIn
- notes
properties:
targetType:
type: string
enum:
- baseline
- budget
decisionStatus:
type: string
enum:
- candidate
- approved
- rejected
evidenceRunRefs:
type: array
minItems: 1
items:
type: string
previousValueSeconds:
type: number
minimum: 0
proposedValueSeconds:
type:
- number
- 'null'
rationaleCode:
type: string
enum:
- lane-scope-change
- infrastructure-shift
- post-improvement-reset
- sustained-erosion
- noise-rejected
- manual-hold
recordedIn:
type: string
description: Active spec path or implementation PR reference for the approved or rejected decision.
notes:
type: string
TrendSummaryCycle:
type: object
additionalProperties: false
required:
- cycleId
- generatedAt
- laneSummaries
- laneAssessments
properties:
cycleId:
type: string
generatedAt:
type: string
format: date-time
laneSummaries:
type: array
items:
$ref: '#/components/schemas/CycleLaneSummary'
laneAssessments:
type: array
items:
$ref: '#/components/schemas/DriftAssessment'
hotspotSnapshots:
type: array
items:
$ref: '#/components/schemas/HotspotSnapshot'
recalibrationDecisions:
type: array
items:
$ref: '#/components/schemas/RecalibrationDecision'
artifactPublicationStatus:
type: array
items:
type: string
warnings:
type: array
items:
type: string
CycleLaneSummary:
type: object
additionalProperties: false
required:
- laneId
- currentRuntimeSeconds
- budgetSeconds
- assessment
properties:
laneId:
type: string
enum:
- fast-feedback
- confidence
- heavy-governance
- browser
- junit
- profiling
currentRuntimeSeconds:
type: number
minimum: 0
previousComparableSeconds:
type:
- number
- 'null'
baselineSeconds:
type:
- number
- 'null'
budgetSeconds:
type: number
minimum: 0
assessment:
$ref: '#/components/schemas/DriftAssessment'
hotspotSnapshot:
$ref: '#/components/schemas/HotspotSnapshot'
warnings:
type: array
items:
type: string

View File

@ -0,0 +1,192 @@
# Data Model: Test Runtime Trend Reporting & Baseline Recalibration
This feature adds repository-owned governance artifacts only. It does not add product database tables. All objects below are implemented as manifest metadata, generated JSON payloads, markdown summaries, or guard-test fixtures derived from the existing lane report outputs.
## 1. LaneTrendPolicy
**Purpose**: Defines the lane-specific rules for bounded history retention, comparable-window evaluation, hotspot visibility, and recalibration guidance.
| Field | Type | Description |
|-------|------|-------------|
| `laneId` | string | Canonical lane identifier (`fast-feedback`, `confidence`, `heavy-governance`, `browser`, `junit`, `profiling`). |
| `workflowProfile` | string | Workflow profile that owns the lane history source in CI. |
| `retentionLimit` | integer | Max history records retained for the lane. |
| `comparisonWindowSize` | integer | Number of recent comparable records used for drift evaluation. |
| `minimumComparableSamples` | integer | Required sample count before a stable non-`unstable` health class is allowed. |
| `varianceFloorSeconds` | integer | Minimum meaningful delta for the lane, aligned with current enforcement tolerance. |
| `nearBudgetHeadroomSeconds` | integer | Headroom threshold for `budget-near`. |
| `hotspotFamilyLimit` | integer | Max family deltas shown in readable summaries. |
| `hotspotFileLimit` | integer | Max file hotspots shown in readable summaries. |
| `slowestEntryRetention` | integer | Max slowest test entries retained in JSON evidence. |
| `recalibrationPolicy` | array | Rule summary for acceptable baseline and budget recalibration triggers. |
**Relationships**
- One `LaneTrendPolicy` governs many `LaneTrendRecord` entries for the same lane.
- One `LaneTrendPolicy` informs one `TrendComparisonWindow`, one `LaneDriftAssessment`, and zero or more `RecalibrationDecisionRecord` entries per reporting cycle.
**Validation Rules**
- `retentionLimit` must be greater than or equal to `comparisonWindowSize`.
- `minimumComparableSamples` must be at least 3.
- `varianceFloorSeconds` must align with or exceed the lane's existing enforcement tolerance.
- Primary lanes use a larger retention window than support lanes.
## 2. LaneTrendRecord
**Purpose**: Captures the per-run evidence snapshot that can safely be compared over time.
| Field | Type | Description |
|-------|------|-------------|
| `runRef` | string | Stable run reference from CI or local execution. |
| `laneId` | string | Governed lane identifier. |
| `workflowId` | string | Workflow profile or logical workflow owner for the run. |
| `triggerClass` | string | Pull request, mainline push, manual, scheduled, or local classification. |
| `generatedAt` | datetime | When the record was emitted. |
| `wallClockSeconds` | number | Current lane runtime in seconds. |
| `baselineSeconds` | number or null | Current comparison baseline for the lane if defined. |
| `baselineSource` | string | Manifest source or comparison source that supplied the baseline. |
| `budgetSeconds` | number | Current lane budget threshold in seconds. |
| `budgetStatus` | string | Current lane budget status from the existing budget evaluator. |
| `blockingStatus` | string | Whether the current CI context blocks on this outcome. |
| `comparisonFingerprint` | string | Hash or structured fingerprint capturing comparability boundaries. |
| `classificationTotals` | array | Runtime grouped by current classification totals. |
| `familyTotals` | array | Runtime grouped by current family totals. |
| `hotspotFiles` | array | Current dominant hotspot files. |
| `slowestEntries` | array | Current slowest test entries, capped by policy. |
| `artifactRefs` | array | References to the summary, report, budget, JUnit, and history artifacts backing the record. |
**Validation Rules**
- A record must derive from the same lane's current `summary.md`, `report.json`, `budget.json`, and available JUnit output.
- `comparisonFingerprint` must be present for any record eligible for comparison.
- `wallClockSeconds`, `budgetSeconds`, and `generatedAt` are required.
- `slowestEntries` must not exceed the lane policy retention cap.
## 3. TrendComparisonWindow
**Purpose**: Represents the bounded comparable history used to evaluate one lane in one reporting cycle.
| Field | Type | Description |
|-------|------|-------------|
| `laneId` | string | Governed lane identifier. |
| `policyRef` | string | Reference to the governing `LaneTrendPolicy`. |
| `currentRecord` | object | The latest `LaneTrendRecord`. |
| `previousComparableRecord` | object or null | The most recent prior comparable record, if one exists. |
| `comparableRecords` | array | Ordered comparable records used for trend evaluation. |
| `excludedRecords` | array | Recent records skipped because of fingerprint mismatch or invalid evidence. |
| `windowStatus` | enum | `stable`, `insufficient-history`, `scope-changed`, or `noisy`. |
| `sampleCount` | integer | Number of comparable records in the active window. |
**Validation Rules**
- Every comparable record must share the same `comparisonFingerprint`.
- `sampleCount` may not exceed `comparisonWindowSize`.
- `previousComparableRecord` must be the immediately preceding entry in `comparableRecords` when present.
- `windowStatus` becomes `insufficient-history` whenever `sampleCount` is below `minimumComparableSamples`.
## 4. LaneDriftAssessment
**Purpose**: Summarizes the current drift verdict for one lane using the bounded comparison window.
| Field | Type | Description |
|-------|------|-------------|
| `laneId` | string | Governed lane identifier. |
| `healthClass` | enum | `healthy`, `budget-near`, `trending-worse`, `regressed`, or `unstable`. |
| `deltaToPreviousSeconds` | number or null | Current runtime delta vs previous comparable run. |
| `deltaToPreviousPercent` | number or null | Percent delta vs previous comparable run. |
| `deltaToBaselineSeconds` | number or null | Current runtime delta vs lane baseline. |
| `deltaToBaselinePercent` | number or null | Percent delta vs lane baseline. |
| `budgetHeadroomSeconds` | number | Remaining headroom before budget breach. |
| `worseningStreak` | integer | Count of recent comparable records showing meaningful worsening. |
| `varianceObservedSeconds` | number | Effective variance observed across the active window. |
| `recalibrationRecommendation` | enum | `none`, `investigate`, `review-baseline`, or `review-budget`. |
| `summaryLine` | string | Human-readable explanation emitted into markdown summaries. |
**Validation Rules**
- `healthClass` may only be non-`unstable` when the comparison window has at least `minimumComparableSamples` comparable records.
- `recalibrationRecommendation` must remain separate from `healthClass`.
- `budgetHeadroomSeconds` may be negative only when the lane is over budget.
## 5. HotspotTrendSnapshot
**Purpose**: Captures how the dominant runtime contributors changed between the current and previous comparable run.
| Field | Type | Description |
|-------|------|-------------|
| `laneId` | string | Governed lane identifier. |
| `familyDeltas` | array | Top family-level deltas with current seconds, previous seconds, and delta values. |
| `fileHotspots` | array | Top file hotspots with current/previous runtime and rank movement. |
| `newEntrants` | array | Families or files newly entering the visible hotspot set. |
| `droppedEntrants` | array | Families or files leaving the visible hotspot set. |
| `evidenceAvailability` | enum | `available` or `unavailable`, used when JUnit or attribution evidence is missing. |
**Validation Rules**
- Human-readable summaries must cap output at the policy's family/file limits.
- JSON evidence may retain more detail, but must not exceed `slowestEntryRetention`.
- If hotspot evidence is unavailable, the summary must say so explicitly.
## 6. RecalibrationDecisionRecord
**Purpose**: Records structured evidence for a proposed, approved, or rejected baseline/budget recalibration.
| Field | Type | Description |
|-------|------|-------------|
| `laneId` | string | Governed lane identifier. |
| `targetType` | enum | `baseline` or `budget`. |
| `decisionStatus` | enum | `candidate`, `approved`, or `rejected`. |
| `evidenceRunRefs` | array | Comparable runs supporting the decision. |
| `previousValueSeconds` | number | Existing baseline or budget value. |
| `proposedValueSeconds` | number or null | Proposed replacement value. |
| `rationaleCode` | enum | `lane-scope-change`, `infrastructure-shift`, `post-improvement-reset`, `sustained-erosion`, `noise-rejected`, or `manual-hold`. |
| `recordedIn` | string | Active spec path or implementation PR reference where the decision is documented. |
| `notes` | string | Concise reviewer-facing explanation. |
**Validation Rules**
- Approved baseline changes require at least one accepted rationale tied to scope or environment truth.
- Approved budget changes require a stronger evidence window than approved baseline changes.
- Rejected decisions must retain the rejection reason.
- The artifact may propose candidates, but approval remains human-controlled.
## 7. TrendSummaryCycle
**Purpose**: Represents one generated trend-aware reporting cycle across the relevant lanes.
| Field | Type | Description |
|-------|------|-------------|
| `cycleId` | string | Reporting-cycle identifier, typically anchored to the current lane run or summary generation timestamp. |
| `generatedAt` | datetime | When the cycle summary was emitted. |
| `laneSummaries` | array | Per-lane summary entries containing `laneId`, current runtime, previous comparable runtime, baseline, budget, and the embedded drift assessment used by the readable summary surface. |
| `laneAssessments` | array | `LaneDriftAssessment` items for all relevant lanes. |
| `hotspotSnapshots` | array | `HotspotTrendSnapshot` items for lanes with available evidence. |
| `recalibrationDecisions` | array | Candidate, approved, or rejected recalibration records emitted for the cycle. |
| `artifactPublicationStatus` | array | Whether required current-run and history artifacts were published successfully. |
| `warnings` | array | Legibility notes such as missing comparable history or unavailable hotspot evidence. |
**Validation Rules**
- Every relevant primary lane must have exactly one `laneSummaries` entry and exactly one `LaneDriftAssessment` per cycle.
- Each `laneSummaries` entry must expose the current runtime, previous comparable runtime, baseline, budget, and embedded health assessment needed by the readable summary surface.
- `warnings` must be explicit when any required evidence is unavailable.
- The cycle summary must stay readable without requiring a second dashboard surface.
## State Transitions
### LaneDriftAssessment.healthClass
- `unstable` -> `healthy`: allowed once there are enough comparable samples and the lane is comfortably below budget without sustained worsening.
- `unstable` -> `budget-near`: allowed once there are enough comparable samples and budget headroom falls inside the near-budget window.
- `unstable` -> `trending-worse`: allowed once there are enough comparable samples and worsening exceeds the lane variance floor across the bounded window.
- `healthy` <-> `budget-near`: allowed as headroom enters or leaves the near-budget band.
- `healthy` or `budget-near` -> `trending-worse`: allowed when sustained worsening appears without a budget breach.
- `trending-worse` -> `regressed`: allowed when the lane breaches budget or shows a materially worse repeated trend strong enough to stop calling it merely erosion.
- Any state -> `unstable`: allowed when comparability breaks, history is insufficient, or the window is too noisy to classify reliably.
### RecalibrationDecisionRecord.decisionStatus
- `candidate` -> `approved`: allowed only by explicit human review with structured evidence.
- `candidate` -> `rejected`: allowed when the evidence is noisy, incomplete, or policy says repository truth should not move.
- `approved` and `rejected`: terminal statuses for the recorded decision.

View File

@ -0,0 +1,174 @@
# Implementation Plan: Test Runtime Trend Reporting & Baseline Recalibration
**Branch**: `211-runtime-trend-recalibration` | **Date**: 2026-04-17 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/211-runtime-trend-recalibration/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/211-runtime-trend-recalibration/spec.md`
## Summary
Implement Spec 211 by extending the existing repo-truth test-governance seams in `TestLaneManifest`, `TestLaneBudget`, `TestLaneReport`, the repo-root reporting wrapper, and the current CI artifact bundles so each governed lane can emit bounded runtime history, current-vs-previous-vs-baseline-vs-budget summaries, lane-first drift states, hotspot deltas, and explicit recalibration recommendations without introducing product database persistence or a second analytics platform.
## Technical Context
**Language/Version**: PHP 8.4.15 for repo-truth governance logic, Bash for repo-root wrappers, GitHub-compatible Gitea Actions workflow YAML under `.gitea/workflows/`, plus JSON Schema and logical OpenAPI for repository contracts
**Primary Dependencies**: Laravel 12, Pest v4, PHPUnit 12, Filament v5, Livewire v4, Laravel Sail, Gitea Actions backed by `act_runner`, uploaded artifact bundles, and the existing `Tests\Support\TestLaneManifest`, `TestLaneBudget`, and `TestLaneReport` seams
**Storage**: SQLite `:memory:` for lane execution, filesystem artifacts under `apps/platform/storage/logs/test-lanes`, staged CI bundles under `.gitea-artifacts/<workflow-profile>`, bounded derived trend/history artifacts adjacent to current lane artifacts, and no new product database persistence
**Testing**: Existing Pest lane and workflow guard suites, new repo-level trend/history/recalibration guard coverage, and representative local plus Gitea artifact sequences for primary lanes
**Validation Lanes**: `fast-feedback` and `confidence` for the narrowest proving path, with representative `heavy-governance`, `browser`, `junit`, and `profiling` evidence used only where hotspot attribution or cross-lane trend behavior needs proof
**Target Platform**: TenantAtlas monorepo on Gitea Actions with `act_runner`, Docker-isolated Sail jobs, repo-root lane/report wrappers, and local developer validation from the repository root
**Project Type**: Monorepo with a Laravel platform app and separate Astro website; this feature is scoped to repository/platform test governance only
**Performance Goals**: Produce lane summaries that remain understandable in under two minutes, classify drift from at least three comparable samples without duplicating full lane reruns, and keep hotspot trend visibility bounded to the dominant contributors rather than exhaustive historical detail
**Constraints**: Repo truth first; no product routes, panels, assets, or dependencies; no new product DB tables; lane-first reporting remains primary; baselines and budgets stay separate; recalibration is explicit; history stays bounded and lightweight; cross-run comparison must work from existing artifact bundles or explicit local inputs rather than assuming unlimited shared storage
**Scale/Scope**: Four primary governed lanes plus two support lanes, at least three comparable samples required for meaningful status, rolling bounded history per lane, and top hotspot visibility based on existing family/classification attribution and slowest-entry reporting
### Filament v5 Implementation Notes
- **Livewire v4.0+ compliance**: Preserved. This feature governs repository test-runtime reporting only and does not alter the Filament or Livewire runtime stack.
- **Provider registration location**: Unchanged. Existing panel providers remain registered in `bootstrap/providers.php`.
- **Global search rule**: No globally searchable resources are added or modified.
- **Destructive actions**: No runtime destructive actions are introduced. Existing confirmation and authorization behavior remain unchanged.
- **Asset strategy**: No panel or shared assets are added. Existing `filament:assets` deployment behavior remains unchanged.
- **Testing plan**: Add or update Pest guards for trend-history contracts, bundle discovery and hydration semantics, JSON schema plus logical OpenAPI contract sync validation, drift classification, recalibration evidence, hotspot delta output, wrapper/report integration, artifact staging/export behavior, timed review-speed acceptance, and representative multi-run evidence for the primary lanes.
## Test Governance Check
- **Affected validation lanes**: `fast-feedback` and `confidence` are the narrowest proving lanes; `heavy-governance`, `browser`, `junit`, and `profiling` remain evidence inputs only when the trend layer needs hotspot or cross-lane proof.
- **Narrowest proving command(s)**: `./scripts/platform-test-lane fast-feedback`, `./scripts/platform-test-report fast-feedback`, `./scripts/platform-test-lane confidence`, and `./scripts/platform-test-report confidence`.
- **Fixture / helper cost risks**: Low and bounded to repo-level report/history fixtures, manifest metadata, and guard helpers. The implementation must not add shared product fixtures, broaden default setup, or widen lane membership.
- **Heavy-family additions or promotions**: None. The feature consumes existing heavy/browser lanes as evidence sources and must not promote new coverage into them by accident.
- **Budget / baseline / trend follow-up**: Drift thresholds, bounded-history size, and any approved baseline or budget recalibration notes must be recorded in the active spec or implementation PR, with quickstart serving only as supplemental reproduction guidance rather than the delivery record.
- **Why no dedicated follow-up spec is needed**: Spec 211 is itself the structural trend-governance feature. After rollout, ordinary threshold upkeep should return to the normal feature-spec workflow unless recurring pain or another lane-model change appears.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS. No inventory, backup, or snapshot product truth changes.
- Read/write separation: PASS. This is repository-only reporting and governance work with no end-user mutations.
- Graph contract path: PASS. No Microsoft Graph calls or contract-registry changes.
- Deterministic capabilities: PASS. No capability resolver, role mapping, or authorization registry changes.
- RBAC-UX, workspace isolation, tenant isolation: PASS. No runtime routes, policies, or tenant/workspace access behavior changes.
- Run observability and Ops-UX: PASS. Trend artifacts remain filesystem- and bundle-based and do not introduce `OperationRun` changes.
- Data minimization: PASS. Trend history must remain derived from summary/report/budget outputs and must not store secrets, tenant payloads, or raw environment detail.
- Test governance (TEST-GOV-001): PASS WITH WORK. The feature must keep the narrowest proving lane explicit, avoid widening heavy lanes, and document any threshold or recalibration follow-up as part of the active delivery artifact.
- Proportionality and bloat control: PASS WITH LIMITS. The new history artifact, drift states, and recalibration rules are justified because per-run evidence alone cannot support trend-based governance. The implementation must stay inside the existing lane/report seams and avoid turning trend logic into a generalized analytics framework.
- TEST-TRUTH-001: PASS WITH WORK. Trend output must remain derived from real lane artifacts and comparable evidence windows, not optimistic labels or hand-maintained spreadsheets.
- Filament/UI constitutions: PASS / NOT APPLICABLE. No operator-facing runtime UI, action surfaces, badges, or panels are changed.
**Phase 0 Gate Result**: PASS
- The feature stays bounded to repository test-governance artifacts, history windows, trend evaluation, and documentation.
- No new product database truth, Graph seams, runtime routes, or authorization planes are introduced.
- The implementation extends existing lane/report structures rather than inventing a separate monitoring subsystem.
## Project Structure
### Documentation (this feature)
```text
specs/211-runtime-trend-recalibration/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── test-runtime-trend-history.schema.json
│ └── test-runtime-trend.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
.gitea/
├── workflows/
│ ├── test-pr-fast-feedback.yml
│ ├── test-main-confidence.yml
│ ├── test-heavy-governance.yml
│ └── test-browser.yml
apps/
├── platform/
│ ├── tests/
│ │ ├── Feature/Guards/
│ │ └── Support/
│ │ ├── TestLaneManifest.php
│ │ ├── TestLaneBudget.php
│ │ └── TestLaneReport.php
│ └── storage/logs/test-lanes/
scripts/
├── platform-test-lane
├── platform-test-report
└── platform-test-artifacts
README.md
```
**Structure Decision**: Keep trend truth in the existing `TestLaneManifest` / `TestLaneBudget` / `TestLaneReport` seams, extend the repo-root reporting flow rather than adding a second execution surface, and keep historical evidence adjacent to the existing lane artifact root and CI bundles so no new database or generic analytics layer is introduced.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| Repo-level trend history artifact | Multi-run drift and recalibration cannot be justified from one run plus README prose | Comparing only current vs previous or current vs baseline cannot distinguish sustained erosion, noise, and scope-change boundaries |
| Repo-level drift health states | Reviewers need consistent intermediate states between healthy and hard failure | A binary green/red view hides budget-near erosion and treats one-off spikes like structural regression |
## Proportionality Review
- **Current operator problem**: Maintainers can enforce budgets per run but cannot yet see whether runtime is eroding, whether a hotspot is becoming dominant, or whether baseline/budget recalibration is justified.
- **Existing structure is insufficient because**: Current lane reports describe one execution at a time and only include limited baseline comparison for narrow historical cases; they do not retain a bounded comparable window or policy-driven drift classification.
- **Narrowest correct implementation**: Extend the existing lane/report contract with bounded history, derived trend evaluation, and explicit recalibration guidance using the same lane artifact root and CI bundles.
- **Ownership cost created**: The repo must maintain history-window policy, drift thresholds, hotspot-delta output, recalibration guidance, and a small set of guard tests validating those semantics.
- **Alternative intentionally rejected**: A new database table, a git-tracked history file committed on every run, or a generalized analytics dashboard, because each would import more persistence or framework weight than the repository currently needs.
- **Release truth**: Current-release repository truth needed to make Specs 206 through 210 durable over time.
## Phase 0 — Research (complete)
- Output: [research.md](./research.md)
- Resolved key decisions:
- Keep trend history and summaries adjacent to the existing lane artifact contract instead of creating a second storage system.
- Treat uploaded Gitea artifact bundles as the shared CI history source, with explicit local artifact input as the fallback for local validation and reproducible examples.
- Use a bounded rolling window per lane with a minimum comparable sample count before declaring stable health states.
- Reuse existing family/classification attribution and slowest-entry output for hotspot trends instead of archiving exhaustive per-test history.
- Separate lane health classification from recalibration recommendation so budgets and baselines do not collapse into a single status.
- Extend the existing summary/report artifacts with trend-specific outputs and sections instead of creating a dashboard or parallel reporting surface.
- Keep recalibration explicit and policy-driven, with different acceptable triggers for baseline changes and budget changes.
## Phase 1 — Design & Contracts (complete)
- Output: [data-model.md](./data-model.md) formalizes lane trend policy, trend records, comparison windows, drift assessments, hotspot trend snapshots, recalibration decisions, and cycle summaries.
- Output: [contracts/test-runtime-trend-history.schema.json](./contracts/test-runtime-trend-history.schema.json) defines the repository contract for bounded lane history, trend evaluation, hotspot deltas, and recalibration evidence.
- Output: [contracts/test-runtime-trend.logical.openapi.yaml](./contracts/test-runtime-trend.logical.openapi.yaml) captures the logical contract for updating one lane history window, evaluating one lane trend, evaluating recalibration, and emitting a cycle summary.
- Output: [quickstart.md](./quickstart.md) provides the implementation order, validation commands, and representative multi-run evidence checklist.
### Post-design Constitution Re-check
- PASS: No runtime routes, panels, Graph seams, or authorization planes are introduced.
- PASS: Trend history remains repository-owned and derived from existing lane artifacts rather than new product persistence.
- PASS: The design stays lane-first and keeps hotspot reporting supportive rather than dominant.
- PASS WITH WORK: The bounded history window and Gitea artifact hydration must remain lightweight and optional enough for local validation without assuming unlimited external retention.
- PASS WITH WORK: Baseline and budget updates must remain explicit manifest/spec changes backed by evidence, not runtime self-mutation.
## Phase 2 — Implementation Planning
`tasks.md` should cover:
- Auditing `TestLaneManifest`, `TestLaneBudget`, `TestLaneReport`, `scripts/platform-test-report`, and `scripts/platform-test-artifacts` as the only valid seams for trend history, drift policy, and artifact export.
- Extending `TestLaneManifest` with lane trend policy metadata, bounded-retention rules, comparability requirements, hotspot limits, and recalibration guidance anchors while keeping budgets and baselines distinct.
- Extending `TestLaneReport` so it can read current lane outputs plus a bounded prior artifact window, emit lane trend records, evaluate drift status, compute hotspot deltas, and write trend-aware summary/report/budget payloads.
- Extending `TestLaneBudget` with explicit recalibration recommendation helpers that assess baseline and budget policy separately from current budget outcome.
- Extending `scripts/platform-test-report` so it can discover, select, and hydrate the latest comparable prior history window from uploaded artifact bundles or explicit local artifact directories, then refresh trend-aware outputs without re-running a second full lane.
- Extending `scripts/platform-test-artifacts` and the existing workflow artifact contracts so trend-specific files are staged and uploaded alongside the current summary/report/budget/JUnit bundle.
- Updating `.gitea/workflows/test-pr-fast-feedback.yml`, `.gitea/workflows/test-main-confidence.yml`, `.gitea/workflows/test-heavy-governance.yml`, and `.gitea/workflows/test-browser.yml` only as needed to pass history-source context and export the new trend files, without widening lane execution.
- Adding or updating Pest guards for bounded history contracts, comparability breaks, latest-comparable-bundle hydration, drift-state classification, hotspot delta legibility, recalibration recommendation rules, JSON schema and logical OpenAPI contract sync, and no accidental heavy/browser promotion.
- Updating `README.md` with concise contributor guidance for reading trend summaries, understanding `healthy` / `budget-near` / `trending-worse` / `regressed` / `unstable`, and knowing when recalibration discussion is appropriate.
- Recording at least three sequential comparable samples for each primary lane, one support-lane example from `junit` or `profiling`, at least one healthy case, one budget-near case, one repeated worsening or regressed case, one unstable/noisy case, one justified plus one rejected recalibration case, and one timed reviewer read proving the summary remains decidable within two minutes.
### Contract Implementation Note
- The JSON schema is repository-tooling-oriented and defines the bounded history/trend contract even if the first implementation stores most of that truth in PHP arrays and generated JSON artifacts.
- The OpenAPI file is logical rather than transport-prescriptive. It documents how wrappers, support classes, and CI artifact inputs must interact, not a public HTTP API.
- The design intentionally reuses current lane report/budget artifacts as the canonical current-run evidence and layers bounded history on top.
### Deployment Sequencing Note
- No database migration is planned.
- No asset publish step changes.
- Recommended rollout order: add trend policy metadata and contracts, extend report generation to build trend outputs from explicit local inputs, extend artifact staging and workflow export, validate with local multi-run sequences for `fast-feedback` and `confidence`, then capture representative Gitea bundle sequences for the remaining primary lanes and document any approved recalibration evidence.

View File

@ -0,0 +1,133 @@
# Quickstart: Test Runtime Trend Reporting & Baseline Recalibration
## Preconditions
- Specs 206 through 210 are already implemented and remain the governing baseline for lane selection, budgets, CI workflow routing, and artifact publication.
- Local validation runs from the repository root and uses Sail-backed commands for PHP and test execution.
- At least one prior comparable artifact bundle or prior lane `*-latest.trend-history.json` file is available when validating a non-`unstable` history window locally.
- No database migration, product route, Filament panel, or frontend asset step is required for this feature.
## Planned Artifact Additions
- Extend the existing lane artifact set with `apps/platform/storage/logs/test-lanes/<lane>-latest.trend-history.json`.
- Extend the existing `summary.md`, `report.json`, and `budget.json` outputs with trend-aware sections and fields rather than creating a parallel human-readable artifact surface.
- Stage the new history artifact into the existing `.gitea-artifacts/<workflow-profile>` upload bundle for the owning lane.
## Recommended Implementation Order
1. Extend `TestLaneManifest` with the lane trend policy, bounded retention limits, comparison-fingerprint inputs, and recalibration guidance anchors.
2. Extend `TestLaneReport` so it can read a prior `*-latest.trend-history.json`, append the current `LaneTrendRecord`, trim to the lane retention limit, compute the trend window, emit drift status, and surface hotspot deltas.
3. Extend `TestLaneBudget` with recalibration recommendation helpers that stay separate from current budget outcome.
4. Extend `scripts/platform-test-report` so it refreshes trend-aware outputs after a prior history file has been hydrated into `apps/platform/storage/logs/test-lanes`.
5. Extend `scripts/platform-test-artifacts` and the checked-in artifact contracts so the trend history file is staged and uploaded with the existing lane bundle.
6. Update only the necessary Gitea workflow steps so each lane can hydrate the previous matching history artifact before report generation without widening lane execution.
7. Add or update Pest guard coverage for trend history, drift classes, hotspot deltas, recalibration rules, and workflow/artifact publication contracts.
8. Update `README.md` with reviewer guidance and capture representative validation evidence for the main trend cases.
## Local Validation Flow
### 1. Generate current lane artifacts
```bash
./scripts/platform-test-lane fast-feedback
./scripts/platform-test-lane confidence
./scripts/platform-test-report fast-feedback --skip-latest-history
./scripts/platform-test-report confidence --skip-latest-history
```
### 2. Hydrate prior comparable history for a stable-window validation
Use the wrapper flags instead of manual artifact copying so local runs exercise the same hydration contract as CI.
```bash
./scripts/platform-test-report fast-feedback --history-file=/absolute/path/to/fast-feedback-latest.trend-history.json
./scripts/platform-test-report confidence --history-bundle=/absolute/path/to/comparable-bundle-or-zip
```
### 3. Rebuild workflow-shaped evidence without widening lane execution
```bash
./scripts/platform-test-report fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request --fetch-latest-history
./scripts/platform-test-report confidence --workflow-id=main-confidence --trigger-class=mainline-push --fetch-latest-history
./scripts/platform-test-report heavy-governance --workflow-id=heavy-governance --trigger-class=manual --skip-latest-history
./scripts/platform-test-report browser --workflow-id=browser-manual --trigger-class=manual --skip-latest-history
./scripts/platform-test-report profiling --skip-latest-history
./scripts/platform-test-report junit --skip-latest-history
```
### 4. Stage artifact bundles exactly as CI will publish them
```bash
./scripts/platform-test-artifacts fast-feedback .gitea-artifacts/pr-fast-feedback --workflow-id=pr-fast-feedback --trigger-class=pull-request
./scripts/platform-test-artifacts confidence .gitea-artifacts/main-confidence --workflow-id=main-confidence --trigger-class=mainline-push
```
### 5. Run focused guard coverage and formatting
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
### 6. Time-box one reviewer summary check
Use the generated summary only, set a two-minute timer, and verify that the reviewer can name the health class for each primary lane plus whether recalibration discussion is warranted before opening raw lane outputs.
## Health Class Cheat Sheet
- `healthy`: the lane has enough comparable history, remains comfortably under budget, and recent variance stays below the lane noise floor.
- `budget-near`: the lane is still passing, but its headroom is inside the lane's warning band.
- `trending-worse`: multiple comparable samples are worsening above the documented variance floor.
- `regressed`: the lane is over budget or repeatedly worsening enough that the report should stop calling it normal erosion.
- `unstable`: the report is intentionally refusing a stronger label because the window is too short, too noisy, or no longer comparable.
Recalibration is separate from health. The report can emit `candidate`, `approved`, or `rejected` baseline or budget decisions, but it never mutates repository truth automatically.
## Recorded Evidence Snapshot (2026-04-17)
| Scenario | Lane | Runtime Window | Outcome |
|----------|------|----------------|---------|
| Live cold-start wrapper run | `fast-feedback` | current `120.29s`, previous `120.29s`, baseline `176.74s`, budget `200s` | `unstable`, hotspot evidence unavailable, budget recalibration rejected (`manual-hold`) because only two comparable samples existed |
| Stable healthy window | `fast-feedback` | current `176.10s`, previous `175.60s`, baseline `176.74s`, budget `200s` | `healthy`, no recalibration recommended |
| Stable budget-near window | `confidence` | current `433.00s`, previous `430.00s`, baseline `394.38s`, budget `450s` | `budget-near`, investigate before the lane becomes a repeated blocker |
| Noisy window | `fast-feedback` | current `170.00s`, previous `195.00s`, baseline `176.74s`, budget `200s` | `unstable` with `windowStatus=noisy`, so the spike is treated as noise instead of structural regression |
| Hotspot-stable example | `confidence` | current `394.38s`, previous `401.12s`, baseline `394.38s`, budget `450s` | `healthy`; dominant families stayed flat and the top files remained the baseline compare matrix pair plus onboarding-wizard enforcement |
| Approved baseline recalibration | `fast-feedback` | current `176.30s`, previous `176.00s`, baseline reset from `176.74s` to `182.00s`, budget `200s` | baseline recalibration recorded as `approved` with rationale `post-improvement-reset` after the lane stabilized |
| Rejected budget recalibration | `fast-feedback` | current `193.00s`, previous `176.00s`, baseline `176.74s`, budget `200s` | `budget-near`, but budget recalibration stayed `rejected` with rationale `noise-rejected` |
| Candidate budget review | `confidence` | current `460.00s`, previous `420.00s`, baseline `394.38s`, budget `450s` | `regressed`, budget review emitted as a `candidate` only after a five-run evidence window |
| Primary-lane cold starts | `browser`, `heavy-governance` | `109.67s/150s` and `228.34s/300s` | both reported `unstable` on first refresh, which is the intended cold-start behavior |
| Support-lane path | `profiling`, `junit` | `2701.51s/3000s` and `380.14s/450s` | both wrappers now emit bounded `trend-history.json`; `junit` support-lane report refresh was repaired so the documented command actually works |
## Representative Evidence Set
Capture at least one example for each of the following before calling the feature complete:
1. Three sequential comparable samples for each primary lane: `fast-feedback`, `confidence`, `heavy-governance`, and `browser`.
2. `healthy`: current runtime comfortably below budget with stable or improving recent comparable history.
3. `budget-near`: current runtime remains under budget but inside the lane's near-budget headroom band.
4. `trending-worse`: a bounded comparable window shows repeated worsening that is larger than the lane noise floor.
5. `regressed`: a budget breach or materially repeated worsening is clearly visible.
6. `unstable`: insufficient comparable history, fingerprint mismatch, or noisy evidence makes a stable label unsafe.
7. Approved recalibration case: explicit evidence shows why repository truth should change.
8. Rejected recalibration case: explicit evidence shows why repository truth should stay unchanged.
9. One support-lane example from `junit` or `profiling` when it materially improves hotspot or comparison evidence.
Each recorded example should name the lane, current runtime, previous runtime, baseline, budget, health class, hotspot summary, and the recalibration conclusion when relevant.
Material runtime drift, bundle-hydration caveats, and approved or rejected recalibration follow-up must be recorded in `specs/211-runtime-trend-recalibration/spec.md` or the active implementation PR. This quickstart may mirror the same evidence, but it does not replace the delivery record.
## CI Rollout Notes
- CI should hydrate the previous matching `*-latest.trend-history.json` from the most recent comparable uploaded artifact bundle before the report refresh step.
- The uploaded bundle for each governed workflow must include the refreshed `*-latest.trend-history.json` so the next run only needs one prior bundle.
- The workflow-owned refresh steps now pass `--fetch-latest-history` together with `TENANTATLAS_GITEA_TOKEN` and top-level `actions: read` plus `contents: read` permissions so bundle discovery stays explicit.
- Pull request and `dev` push validation remain the narrowest proving paths; heavy/browser/manual/scheduled lanes provide representative cross-lane evidence and must not be widened.
## Final Review Checklist
- Trend policy lives in repository truth, not workflow prose.
- `summary.md`, `report.json`, `budget.json`, and `*-latest.trend-history.json` agree on lane runtime and health class.
- Baseline and budget recalibration remain explicit, reviewable, and separate.
- Hotspot summaries stay readable and bounded.
- A timed reviewer dry run confirms the generated summary remains decidable within two minutes.
- The implementation does not add product persistence, routes, assets, or a second analytics surface.

View File

@ -0,0 +1,73 @@
# Research: Test Runtime Trend Reporting & Baseline Recalibration
## Decision 1: Persist bounded lane history as an artifact beside the existing lane report outputs
- **Decision**: Add one bounded `trend-history.json` artifact per governed lane under the existing lane artifact root and stage that same file into the existing CI upload bundle for the lane's workflow profile.
- **Rationale**: The repo already treats `summary.md`, `report.json`, `budget.json`, and `junit.xml` as the canonical lane outputs. A bounded history file beside those artifacts preserves repository truth, avoids product persistence, and gives the next CI run a portable history window without inventing a database, cache, or commit-on-every-run workflow.
- **Alternatives considered**:
- Store history in a new product database table: rejected because the feature is repository governance, not application runtime truth.
- Commit history files back into the repository on every run: rejected because runtime-generated governance evidence should not create noisy git churn.
- Reconstruct history from many previous artifact bundles every time: rejected because it depends on broader artifact retention and more CI/API complexity than necessary.
## Decision 2: Use the latest matching uploaded artifact bundle as the shared CI history source
- **Decision**: Hydrate the next lane history window from the latest matching uploaded bundle for the same lane/workflow profile when CI credentials are available, and allow an explicit local artifact directory or prior `trend-history.json` file as the fallback source for local validation.
- **Rationale**: Once each bundle already contains the full bounded history window, the next run only needs the most recent comparable bundle rather than a multi-run artifact crawl. This stays lightweight and lets local development validate the exact same contract using checked-out or copied artifacts.
- **Alternatives considered**:
- Depend on an external metrics store or dashboard backend: rejected because it would import a second analytics system.
- Assume shared workspace persistence across CI runs: rejected because Gitea runners should be treated as ephemeral.
- Require local developers to manually build history state for every validation: rejected because the workflow would be too fragile and easy to bypass.
## Decision 3: Keep trend policy in `TestLaneManifest` and trend evaluation inside the existing reporting seams
- **Decision**: Extend `TestLaneManifest` with lane trend metadata and keep history/trend generation inside `TestLaneReport`, with `TestLaneBudget` providing recalibration and tolerance-aware recommendation helpers.
- **Rationale**: Budgets, workflow bindings, artifact contracts, and existing comparison rules already live in these seams. Trend reporting is a governance extension of the same truth, not a separate subsystem. Keeping policy and evaluation together prevents duplication between wrappers, tests, and CI configuration.
- **Alternatives considered**:
- Introduce a new generalized analytics service layer: rejected because there is only one real consumer and one real domain.
- Push all trend logic into shell scripts: rejected because the classification rules and JSON contracts belong in versioned PHP support code with guard tests.
- Scatter thresholds across workflow YAML and README prose: rejected because repository truth would become inconsistent.
## Decision 4: Use a bounded comparable window with explicit retention and comparison fingerprints
- **Decision**: Retain the latest 20 records for primary lanes (`fast-feedback`, `confidence`, `heavy-governance`, `browser`) and the latest 10 records for support lanes (`junit`, `profiling`); evaluate drift from the latest 5 comparable records and require at least 3 comparable samples before assigning a stable non-`unstable` health class. Each history record carries a comparison fingerprint built from lane ID, workflow ID, trigger class, contract version, baseline source, and lane-scope signature.
- **Rationale**: Twenty primary-lane records preserve enough runway to separate short-term noise from structural erosion while staying small enough for artifact bundles. Five recent comparable records are enough to show worsening or stabilization trends without overfitting old runs. The comparison fingerprint prevents silent apples-to-oranges comparisons when lane membership, workflow class, or contract shape changes.
- **Alternatives considered**:
- Retain every historical record forever: rejected because the feature explicitly calls for bounded lightweight history.
- Compare only the immediately previous run: rejected because it cannot reliably distinguish streaks, noise, and recalibration boundaries.
- Compare by lane ID alone: rejected because workflow class and lane-scope changes would produce misleading trends.
## Decision 5: Derive health classes from existing variance tolerances plus a trend policy, not from raw runtime deltas alone
- **Decision**: Classify lane health with the fixed vocabulary `healthy`, `budget-near`, `trending-worse`, `regressed`, and `unstable`. Use the existing lane-specific variance allowances from the current enforcement profiles as the minimum noise floor, combine them with a near-budget headroom rule, and reserve `unstable` for insufficient comparable history, comparison-fingerprint breaks, or high variance/noisy windows.
- **Rationale**: The repo already documents lane-specific tolerance in budget enforcement. Reusing that allowance as the floor for trend significance keeps the new model aligned with current governance truth and avoids inventing unrelated threshold systems.
- **Alternatives considered**:
- Binary healthy/regressed classification: rejected because it hides erosion before a lane breaches budget.
- Pure percentage-only thresholds: rejected because current lane budgets and tolerances already vary meaningfully in absolute seconds.
- Automatically downgrade every spike to `regressed`: rejected because one-off noise should remain visible without looking structural.
## Decision 6: Keep hotspot trend visibility family-first and summary-friendly
- **Decision**: Reuse `TestLaneReport`'s existing classification totals, family totals, hotspot files, and slowest-entry output; show the top 5 family deltas and top 3 file hotspots in human-readable summaries, while retaining up to the current top 10 slowest entries in JSON evidence.
- **Rationale**: The existing report already derives the expensive attribution data. Trend reporting only needs to answer which dominant contributors worsened or stabilized, not preserve exhaustive per-test history.
- **Alternatives considered**:
- Store and diff every test case over time: rejected because the storage and readability cost is not justified.
- Show only lane-level runtime without hotspot context: rejected because recalibration and regression review would remain too opaque.
- Make hotspot output file-first only: rejected because family-level attribution is the more stable governance lens already used by the repo.
## Decision 7: Separate recalibration recommendation from health status and keep recalibration explicitly human-approved
- **Decision**: Emit recalibration recommendations separately from the lane health class and record explicit evidence for approved or rejected recalibration decisions. Baseline recalibration is only justified by documented lane-scope change, lasting infrastructure change, or deliberate post-improvement reset. Budget recalibration requires a stronger sustained evidence window and must never happen automatically because of a single regression or a noisy streak.
- **Rationale**: Health status answers "what is happening now". Recalibration answers "should repository truth change". Keeping those separate prevents a degraded lane from appearing self-healing just because the tool auto-adjusted the benchmark.
- **Alternatives considered**:
- Auto-adjust baselines or budgets from rolling averages: rejected because it would erase regression history.
- Treat recalibration as free-form README guidance only: rejected because reviewers need a structured evidence record.
- Merge recalibration directly into the health-class vocabulary: rejected because review semantics and current-state semantics are different concerns.
## Decision 8: Extend the existing summary/report surfaces instead of introducing a new dashboard surface
- **Decision**: Add a trend section to the existing lane `summary.md`, add a trend block to the current JSON report payloads, and use `trend-history.json` as the dedicated bounded-history artifact.
- **Rationale**: Maintainers already read the current summary and JSON artifacts. Extending those surfaces makes trend output immediately usable in local runs, CI logs, and uploaded bundles without inventing a parallel UI or artifact family.
- **Alternatives considered**:
- Create a new UI page or dashboard: rejected because this feature is repository-governance-only.
- Emit a second human-readable markdown file for trend alone: rejected because it would split the operator reading surface unnecessarily.
- Keep trend data only inside JSON: rejected because reviewers need readable summaries during ordinary PR and CI triage.

View File

@ -0,0 +1,351 @@
# Feature Specification: Test Runtime Trend Reporting & Baseline Recalibration
**Feature Branch**: `211-runtime-trend-recalibration`
**Created**: 2026-04-17
**Status**: Implemented (local validation complete)
**Input**: User description: "Spec 211 - Test Runtime Trend Reporting & Baseline Recalibration"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot's test-suite governance is now enforceable per run, but maintainers still lack a shared time-series view of how lane runtime, hotspot cost, and budget headroom evolve over time.
- **Today's failure**: A lane can erode gradually without obvious alarm, a noisy outlier can be mistaken for structural regression, and baseline or budget changes can happen without consistent evidence or policy.
- **User-visible improvement**: Contributors and reviewers get readable lane trend summaries that show health, deterioration, hotspot drift, and whether recalibration is justified before a lane becomes a repeated blocker.
- **Smallest enterprise-capable version**: Reuse the existing governed lane artifacts to retain bounded runtime history, compare current versus previous versus baseline versus budget, classify drift states, surface dominant hotspots, and document explicit baseline and budget recalibration rules.
- **Explicit non-goals**: No new lane taxonomy, no new general-purpose analytics platform, no automatic budget inflation, no mandate to optimize every slow file inside this spec, and no unlimited raw-history retention.
- **Permanent complexity imported**: Runtime trend data contract, bounded history artifacts, drift classification vocabulary, hotspot comparison rules, recalibration policy, summary semantics, and contributor guidance.
- **Why now**: Specs 206 through 210 established lane execution, fixture cost reduction, heavy-lane separation, and CI enforcement; without a trend layer the team can only react after drift already starts blocking shared flow.
- **Why not local**: Private spreadsheets or ad hoc comparisons cannot produce shared, reviewable evidence or a consistent recalibration process that survives reviewer and maintainer turnover.
- **Approval class**: Cleanup
- **Red flags triggered**: New historical artifact retention, new drift-status vocabulary, and new recalibration policy. Defense: the feature stays repo-scoped, derives from existing lane outputs, and intentionally avoids becoming a second analytics system.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: No end-user HTTP routes change. The affected surfaces are repository-owned lane reports, trend summaries, recalibration guidance, and CI/runtime artifacts.
- **Data Ownership**: Workspace-owned runtime history artifacts, trend summaries, budget and baseline policy, and contributor guidance. No tenant-owned records or product runtime tables are introduced.
- **RBAC**: No end-user authorization behavior changes. The actors are contributors, reviewers, maintainers, and CI runners consuming the shared test-governance contract.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: yes, but only repository-owned historical runtime and trend artifacts derived from existing governed lane outputs
- **New abstraction?**: yes, but limited to a repo-level trend model, drift classification, and recalibration policy
- **New enum/state/reason family?**: yes, but only repository-level lane health states such as `healthy`, `budget-near`, `trending-worse`, `regressed`, and `unstable`
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Maintainers can enforce budgets per run, but they cannot yet see whether a lane is drifting, whether a hotspot is growing, or whether recalibration is evidence-based instead of reactive.
- **Existing structure is insufficient because**: Current CI evidence is mostly run-by-run and cannot reliably distinguish sustained erosion, legitimate suite growth, or runner noise without manual reconstruction.
- **Narrowest correct implementation**: Add bounded history and derived trend summaries on top of existing governed lane artifacts instead of inventing new lanes, new product persistence, or a broader analytics stack.
- **Ownership cost**: The team must maintain trend retention rules, drift thresholds, recalibration guidance, and representative example evidence as runner behavior and suite composition evolve.
- **Alternative intentionally rejected**: Ad hoc manual comparisons, one-off spreadsheets, or allowing budgets to silently move upward with each overrun.
- **Release truth**: Current-release repository truth required to make Specs 206 through 210 durable over time.
## Problem Statement
Specs 206 through 210 moved TenantPilot's test suite into a governed operating model:
- Lanes and budgets exist.
- Shared fixture cost has been reduced.
- Heavy Filament or Livewire families have been segmented.
- Heavy-governance cost is treated honestly.
- CI runs the governed lanes and evaluates budgets.
What is still missing is the time dimension. The repository can usually tell whether one run is green or red, but it still cannot answer the more strategic questions:
- Is a lane slowly getting worse even though it still passes?
- Is a budget warning noise, early erosion, or a genuine regression?
- Did the suite legitimately grow, or did the budget simply drift upward by habit?
- Are the dominant hotspots stable, worsening, or newly emerging?
Without historical observability and explicit recalibration policy, test governance remains operational rather than strategic.
## Dependencies
- Depends on Spec 206 - Test Suite Governance & Performance Foundation for lane vocabulary, budgets, and checked-in reporting entry points.
- Depends on Spec 207 - Shared Test Fixture Slimming for more credible lane cost signals.
- Depends on Spec 208 - Filament/Livewire Heavy Suite Segmentation for honest separation of expensive families.
- Depends on Spec 209 - Heavy Governance Lane Cost Reduction for a more stable heavy-lane baseline.
- Depends on Spec 210 - CI Test Matrix & Runtime Budget Enforcement for governed CI artifacts, budget evidence, and per-run enforcement semantics.
- Recommended after stable CI lanes, reproducible lane artifacts, and functioning budget enforcement are already available.
- Blocks durable long-horizon budget stewardship and trend-based test governance.
- Does not block normal feature delivery or daily CI execution.
## Goals
- Make runtime evolution visible for each primary lane over time.
- Compare current values against both baselines and budgets.
- Detect budget erosion before hard gates fail repeatedly.
- Define explicit policy for baseline recalibration.
- Define explicit policy for budget recalibration.
- Track hotspot and family cost shifts over time.
- Distinguish runner noise from true regression.
## Non-Goals
- Optimizing every individual slow test file within this spec.
- Creating another lane-segmentation feature.
- Replacing CI budget enforcement rather than complementing it.
- Building a general analytics platform for every CI metric.
- Turning trend reporting into a broad dashboard project unrelated to test runtime governance.
- Requiring unlimited historical retention of raw CI outputs.
## Assumptions
- The lane wrappers and artifact contracts created by Specs 206 through 210 remain the authoritative inputs for any trend layer.
- Representative run references, timestamps, or commit identifiers are available for governed lane outputs.
- History retention can be bounded without losing enough evidence to justify recalibration decisions.
- CI noise is real and should be treated as ordinary variance rather than proof of regression by default.
## Key Decisions
- **Budgets and baselines are different**: A budget is a governance limit, while a baseline is a reference point. They must not drift together automatically.
- **Trend visibility complements hard enforcement**: The existing red or green contract stays in place; trend reporting adds foresight rather than replacing gates.
- **Recalibration must be explicit**: Baseline or budget changes require documented evidence and reasoning.
- **Noise-aware governance matters**: Single noisy runs should not dominate decisions.
- **Lane-first governance remains primary**: File and family hotspots inform the decision, but the lane stays the main governance unit.
- **Historical observability must stay lightweight**: The first slice should aid decisions without becoming a second BI system.
## Test Governance Impact *(mandatory — TEST-GOV-001)*
- **Validation lane(s)**: `fast-feedback`, `confidence`, `heavy-governance`, `browser`, plus `junit` and `profiling` when they supply hotspot or comparison evidence.
- **Why these lanes are sufficient**: They cover the full governed cost classes already recognized by the repository, including both primary operational lanes and the support evidence used to explain hotspots and compare scope.
- **New or expanded test families**: No new product-facing test family is required. The feature may add lightweight repo-level guard coverage for trend parsing, drift classification, recalibration reasoning, and summary generation.
- **Fixture / helper cost impact**: Low and bounded. The feature MUST stay inside repo-level reporting, artifact retention, and documentation. It MUST NOT add shared product fixtures, broaden default setup, or widen heavy suite membership.
- **Heavy coverage justification**: None beyond consuming the existing `heavy-governance` and `browser` lanes as evidence sources. The feature introduces no new heavy-governance or browser scenarios.
- **Budget / baseline / trend impact**: This feature formalizes trend headroom, drift states, and recalibration criteria. Any threshold tuning or material runtime drift or recalibration follow-up discovered during rollout MUST be documented in this spec or the active implementation PR rather than silently absorbed into budgets or left only in quickstart notes.
- **Planned validation commands**: `./scripts/platform-test-lane fast-feedback`, `./scripts/platform-test-lane confidence`, `./scripts/platform-test-report fast-feedback`, and `./scripts/platform-test-report confidence` for routine reviewer validation. Representative `heavy-governance`, `browser`, `junit`, and `profiling` evidence should come from the same checked-in lane/report entry points rather than ad hoc commands.
## Trend Reporting Minimum Surface
### Lane Runtime Trend Model
For each relevant lane, the trend surface must show at least:
- current runtime
- previous comparable runtime
- baseline runtime
- budget target
- delta to previous runtime
- delta to baseline runtime
- current health classification
- recent history window sufficient to show direction rather than a single point
### Runtime History Contract
Each retained trend record must remain reproducible enough to justify later decisions and must preserve at least:
- run, commit, or timestamp reference
- lane name
- measured runtime
- budget outcome or headroom state
- baseline reference used for comparison
- hotspot or family summary when available
- enough provenance to explain whether the record is directly comparable to adjacent runs
### Drift Detection Outcomes
Trend reporting must distinguish at least these lane states:
- `healthy`
- `budget-near`
- `trending-worse`
- `regressed`
- `unstable`
The model must be able to show intermediate deterioration without collapsing every non-healthy case into a single hard failure signal.
### Hotspot Trend Visibility
Trend reporting must expose the dominant cost drivers for each primary lane in a way that shows:
- top cost drivers for the current reporting window
- change against the reference window
- newly dominant families or files
- persistent known hotspots that continue to dominate cost
### Readable Summary Surface
Each reporting cycle must publish a concise summary that makes it immediately clear:
- which lanes are healthy
- which lanes are near budget
- which lanes are worsening or regressed
- whether recalibration should be discussed
- which hotspots dominate the lanes that need attention
## Required Validation Evidence Set
- One recent sequence of at least three comparable run samples for each primary lane: `fast-feedback`, `confidence`, `heavy-governance`, and `browser`.
- One support-lane example from `junit` or `profiling` when it materially improves hotspot or comparison evidence.
- One example each for `healthy`, `budget-near`, `trending-worse` or `regressed`, and `unstable` outcomes.
- One example where legitimate lane-scope change justifies baseline recalibration.
- One example where an overrun does not justify either baseline or budget recalibration.
- Material runtime drift, bundle-hydration caveats, and approved or rejected recalibration follow-up must be recorded in this spec or the active implementation PR; quickstart may mirror the same evidence but does not replace the delivery record.
- Each evidence record must identify the run reference, lane, current runtime, previous runtime, baseline, budget, health class, and hotspot summary or an explicit note that hotspot evidence is unavailable.
## Recorded Validation Evidence (2026-04-17)
| Evidence | Lane | Current / Previous / Baseline / Budget | Health | Hotspots | Recalibration |
|----------|------|-----------------------------------------|--------|----------|---------------|
| Live cold-start wrapper refresh via `./scripts/platform-test-report fast-feedback --skip-latest-history` | `fast-feedback` | `120.29s / 120.29s / 176.74s / 200s` | `unstable` with `windowStatus=insufficient-history` | unavailable | budget `rejected` with `manual-hold` because the comparable window was still too short |
| Representative stable window from generated trend-summary fixtures | `fast-feedback` | `176.73s / 178.91s / 176.74s / 200s` | `healthy` | unavailable | none |
| Representative near-budget window from generated trend-classification fixtures | `confidence` | `433.00s / 430.00s / 394.38s / 450s` | `budget-near` | not the focus of this case | investigate only; no automatic repository-truth change |
| Representative noisy window from generated trend-classification fixtures | `fast-feedback` | `170.00s / 195.00s / 176.74s / 200s` | `unstable` with `windowStatus=noisy` | unavailable | none; the report explicitly treats the spike as noise instead of a structural regression |
| Representative hotspot-stable window from generated trend-summary and trend-hotspots fixtures | `confidence` | `394.38s / 401.12s / 394.38s / 450s` | `healthy` | available; `baseline-compare-matrix-workflow` and `onboarding-wizard-enforcement` stayed flat, with the compare-matrix pair remaining the top file hotspots | none |
| Approved baseline reset from generated recalibration fixtures | `fast-feedback` | `176.30s / 176.00s / 182.00s / 200s` | `healthy` | unavailable | baseline `approved` with `post-improvement-reset` after the lane stabilized |
| Rejected budget movement from generated recalibration fixtures | `fast-feedback` | `193.00s / 176.00s / 176.74s / 200s` | `budget-near` | unavailable | budget `rejected` with `noise-rejected`; repository truth stayed unchanged |
| Candidate budget review from generated recalibration fixtures | `confidence` | `460.00s / 420.00s / 394.38s / 450s` | `regressed` | not the focus of this case | budget `candidate` only after a five-run evidence window, proposed `505s`, still requiring human approval |
| Live primary-lane cold-start refresh via repo-root wrappers | `browser` and `heavy-governance` | `109.67s / n/a / n/a / 150s` and `228.34s / n/a / n/a / 300s` | both `unstable` on first refresh | unavailable until a comparable prior window exists | budget `rejected` with `manual-hold` on both first-pass reports |
| Live support-lane refresh via repo-root wrappers | `profiling` and `junit` | `2701.51s / n/a / n/a / 3000s` and `380.14s / n/a / n/a / 450s` | both `unstable` on first refresh | unavailable on cold start | budget `rejected` with `manual-hold`; the `junit` report wrapper path was repaired during this implementation so the documented command now executes |
- Reviewer dry run: the generated markdown summaries remained decidable from the `## Lane trend` section alone within the intended two-minute review window, without opening the raw JSON payloads.
- Bundle hydration note: workflow-owned report refresh now relies on `--fetch-latest-history` plus `TENANTATLAS_GITEA_TOKEN` and explicit `actions: read` plus `contents: read` permissions to pull the newest comparable artifact bundle before regenerating `trend-history.json`.
- Runtime follow-up note: no baseline or budget changed automatically in repository truth during implementation. All recalibration output stayed advisory unless a fixture or spec entry explicitly marked it approved.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - See Lane Drift Before It Becomes A Repeated Gate (Priority: P1)
As a maintainer reviewing governed test runs, I want lane summaries to compare the current runtime against the previous run, the baseline, and the budget so I can spot erosion before a lane becomes a recurring blocker.
**Why this priority**: Early drift detection is the core value of the feature. Without it, governance remains reactive and only responds after breakage is already frequent.
**Independent Test**: Review a representative run sequence for `fast-feedback` and `confidence`, confirm that the summary shows current, previous, baseline, and budget values, and verify that healthy, near-budget, and worsening cases are distinguishable without manual arithmetic.
**Acceptance Scenarios**:
1. **Given** a lane stays near its baseline with comfortable headroom, **When** the trend summary is generated, **Then** the lane is shown as healthy with current, previous, baseline, and budget values visible.
2. **Given** a lane moves closer to its budget across multiple comparable runs, **When** the trend summary is generated, **Then** the lane is shown as budget-near or trending-worse before repeated hard failures begin.
3. **Given** a single run spikes but adjacent runs remain normal, **When** the trend summary is generated, **Then** the lane is treated as unstable or noisy rather than immediately treated as baseline regression.
---
### User Story 2 - Decide Recalibration With Evidence Instead Of Habit (Priority: P1)
As a maintainer responsible for budgets, I want explicit recalibration rules and supporting trend evidence so I can distinguish legitimate suite growth, lane reshaping, infrastructure change, and true regression.
**Why this priority**: Without explicit policy, every slowdown invites arbitrary budget inflation or blanket refusal to recalibrate, and both outcomes weaken governance.
**Independent Test**: Review one representative justified recalibration case and one rejected recalibration case, and confirm that the report plus policy make the outcome understandable without relying on private notes.
**Acceptance Scenarios**:
1. **Given** a lane slows because approved coverage legitimately expands its scope, **When** maintainers review the trend evidence, **Then** baseline recalibration is presented as discussable rather than automatic.
2. **Given** a lane slows because of a regression without approved scope change, **When** maintainers review the trend evidence, **Then** baseline and budget remain unchanged and follow-up performance work is indicated instead.
3. **Given** only runner noise is present, **When** the trend evidence is reviewed, **Then** no immediate baseline or budget recalibration is recommended.
---
### User Story 3 - Track Dominant Hotspots Over Time (Priority: P2)
As a contributor investigating suite slowdown, I want hotspot trend summaries per lane so I can target the dominant family or file based on persistent evidence rather than a single anecdotal slow run.
**Why this priority**: Lane-level health points maintainers toward trouble, but hotspot trend visibility makes follow-up work actionable.
**Independent Test**: Review representative hotspot summaries for each primary lane across multiple runs and confirm that persistent, worsening, newly dominant, and unavailable hotspot states are visible.
**Acceptance Scenarios**:
1. **Given** the dominant hotspot families change between reporting windows, **When** the summary is generated, **Then** newly dominant families are visible without reading raw per-test output.
2. **Given** a known expensive family remains the major cost driver across several runs, **When** the summary is reviewed, **Then** its persistence is clear enough to support targeted follow-up work.
3. **Given** hotspot detail is unavailable for one reporting cycle, **When** the summary is generated, **Then** the report states that the hotspot evidence is incomplete instead of silently omitting context.
### Edge Cases
- The first rollout window has too little history for a given lane; the summary must clearly mark the comparison as insufficient rather than pretending a stable trend exists.
- Lane membership or scope changes make old and new runs only partially comparable; the report must flag that boundary before trend conclusions are drawn.
- A budget exists but the prior baseline is outdated or missing; the report must surface the mismatch rather than hiding it.
- Several lanes move at once after an infrastructure or runner change; the recalibration policy must prevent accidental budget inflation across the board.
- Hotspot evidence is only partially available for one lane; the lane health summary must remain readable while clearly disclosing the missing hotspot context.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature is repository-only test-governance work. It introduces no Microsoft Graph calls, no product write behavior, no `OperationRun`, and no end-user authorization changes.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature introduces repository-owned historical artifacts, drift states, and recalibration policy only because per-run enforcement alone is insufficient to govern long-horizon suite behavior. The Proportionality Review above explains why bounded derived history is the narrowest correct implementation.
**Constitution alignment (TEST-GOV-001):** The feature covers the affected validation lanes, keeps heavy and browser scope unchanged, avoids new shared fixture cost, documents expected baseline and budget follow-up, and records the minimal reviewer commands above.
### Functional Requirements
- **FR-001 History Coverage**: The repository MUST retain or derive comparable runtime history for each primary governed lane: Fast Feedback, Confidence, Heavy Governance, and Browser. Support lanes such as JUnit or Profiling MUST be included when they materially improve hotspot or comparison evidence.
- **FR-002 Trend Record Contract**: Each retained trend record MUST include a lane identifier, a run or commit reference, measured runtime, baseline reference, budget context, and enough provenance to compare the record with the immediately preceding relevant record.
- **FR-003 Lane Summary Contract**: Each reporting cycle MUST expose, for every relevant lane, the current runtime, previous runtime, baseline, budget, delta to previous run, delta to baseline, and current lane health classification.
- **FR-004 Drift Health States**: The reporting model MUST distinguish at least the states `healthy`, `budget-near`, `trending-worse`, `regressed`, and `unstable`.
- **FR-005 Noise Handling**: A single anomalous run MUST NOT by itself force a lane into the same treatment as repeated deterioration; the trend model MUST differentiate one-off spikes from sustained erosion.
- **FR-006 Baseline Recalibration Policy**: The repository MUST document when a baseline may be reset, when it must remain unchanged, what evidence window is required, and who is expected to justify the decision.
- **FR-007 Budget Recalibration Policy**: The repository MUST document when a budget may change, when it must not change, and which reasons are considered valid, including deliberate lane-scope change, infrastructure shift, or post-improvement tightening.
- **FR-008 Explicit Recalibration Evidence**: Any approved baseline or budget recalibration MUST be tied to documented evidence showing the before-and-after rationale rather than silently adopting the latest run as the new truth.
- **FR-009 Hotspot Trend Visibility**: Each primary lane trend report MUST expose dominant cost drivers and indicate whether a hotspot is stable, worsening, or newly dominant compared with the reference window.
- **FR-010 Readable Summary**: Each reporting cycle MUST publish a concise summary that lets a reviewer tell which lanes are healthy, near budget, worsening, regressed, or candidates for recalibration without opening raw lane outputs first.
- **FR-011 Contributor Guidance**: Repository guidance MUST explain how to read the trend summary, when authors should react to budget-near or worsening status, when recalibration discussion is appropriate, and when a follow-up performance pass is the correct response instead.
- **FR-012 Bounded Retention**: The history model MUST remain lightweight by using bounded retained evidence sufficient for governance decisions rather than requiring unlimited archival of raw run outputs.
- **FR-013 Validation Examples**: Completion of this feature MUST include representative examples covering at least one healthy lane, one budget-near lane, one repeated worsening or regressed lane, one unstable case, and one justified recalibration case.
- **FR-014 Lane-First Governance**: Trend reporting MUST remain lane-first; hotspot detail may inform the decision, but it MUST NOT replace lane-level status as the primary governance unit.
### Non-Functional Requirements
- **NFR-001 Decision Speed**: A reviewer must be able to determine the health class of each governed lane from the summary in under two minutes for a normal reporting cycle.
- **NFR-002 Noise Resilience**: The trend model must reduce false regression calls caused by normal CI variance so that a single noisy run remains an exception rather than the default explanation.
- **NFR-003 Operational Weight**: The trend layer must reuse existing governed lane outputs and must not require duplicate full reruns of every primary lane solely to produce routine reporting.
## Risks
- **Overreacting to CI noise**: If the thresholds are too sensitive, normal runner variability could look like a structural regression.
- **Baseline inflation**: If recalibration is too easy, baseline history loses its value as a reference point.
- **Budget normalization drift**: If every overrun becomes a budget update, the budget model stops functioning as governance.
- **Over-complex reporting**: Too many metrics can make the summary harder to use instead of easier.
- **False precision**: Historical numbers can look more exact than the runner environment really allows.
- **Hotspot overload**: Too much hotspot detail can crowd out the lane-first decision that the report is supposed to support.
## Rollout Guidance
- Define the minimal trend data contract before adding new summary states.
- Introduce per-lane summaries showing current, previous, baseline, and budget values first.
- Add drift classification only after the comparison window is clear.
- Document baseline and budget recalibration policy before tuning thresholds.
- Add hotspot trend visibility for the highest-value lanes after the lane summary is readable.
- Validate the output with real or representative run sequences and adjust thresholds only when the examples show misleading outcomes.
- Keep the first slice minimal and decision-oriented rather than exhaustive.
## Design Rules
- **Budgets are policy, baselines are reference**.
- **Trend output must aid decisions**.
- **No silent recalibration**.
- **Noise-aware, not noise-blind**.
- **Lane-first observability**.
- **Hotspots support, not dominate, governance**.
- **Readable over exhaustive**.
## Deliverables
- A trend-capable runtime history contract or artifact for governed lanes.
- A per-lane trend summary showing current, previous, baseline, budget, and health state.
- A drift-classification model for lane health.
- Documented baseline recalibration policy.
- Documented budget recalibration policy.
- A hotspot trend view for relevant lanes.
- Contributor and reviewer guidance.
- Validation evidence from real or representative governed runs.
### Key Entities *(include if feature involves data)*
- **Lane Trend Record**: A retained runtime snapshot for one governed lane at one reporting point, including runtime, comparison context, and health state.
- **Baseline Reference**: The agreed reference value used to compare later lane runs without acting as the budget itself.
- **Budget Policy**: The governance limit and enforcement posture applied to a lane, distinct from the baseline reference.
- **Drift Status**: The named lane-health classification that distinguishes healthy behavior from near-budget, worsening, regressed, or unstable patterns.
- **Hotspot Trend Snapshot**: A ranked summary of the dominant cost drivers for a lane together with their change relative to the comparison window.
- **Recalibration Decision**: A documented decision that keeps, adjusts, or tightens a baseline or budget based on explicit trend evidence.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: The trend summary covers 100% of primary governed lanes with current runtime, previous runtime, baseline, budget, and health classification visible in the validation evidence.
- **SC-002**: At least three sequential comparable samples are available for each primary governed lane in the validation evidence without requiring manual reconstruction outside repository-owned artifacts or summaries.
- **SC-003**: In the documented validation examples, single noisy outliers are classified differently from repeated deterioration in 100% of cases.
- **SC-004**: The validation evidence includes at least one justified recalibration case and at least one rejected recalibration case, each explainable from retained trend evidence without relying on private notes.
- **SC-005**: For each primary governed lane, the trend output identifies at least the top three dominant cost drivers or explicitly states that hotspot evidence is unavailable.
- **SC-006**: Reviewers can determine within two minutes whether a lane is healthy, budget-near, worsening, regressed, or recalibration-worthy from the generated summary.

View File

@ -0,0 +1,202 @@
# Tasks: Test Runtime Trend Reporting & Baseline Recalibration
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/211-runtime-trend-recalibration/`
**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Required. This feature changes repository test-governance runtime behavior, so each user story includes Pest guard coverage plus focused lane and wrapper validation through Sail and the repo-root test-governance scripts.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently where possible.
## Phase 1: Setup (Shared Context)
**Purpose**: Freeze the real repo-truth seams and artifact boundaries before implementation begins.
- [X] T001 [P] Audit `apps/platform/tests/Support/TestLaneManifest.php`, `apps/platform/tests/Support/TestLaneBudget.php`, `apps/platform/tests/Support/TestLaneReport.php`, `scripts/platform-test-report`, `scripts/platform-test-artifacts`, and `.gitea/workflows/*.yml` as the only valid trend-history and runtime-governance seams before implementation
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Extend the shared manifest, artifact, and wrapper seams that every story depends on.
**Critical**: No user story work should begin until this phase is complete.
- [X] T002 Extend `apps/platform/tests/Support/TestLaneManifest.php` with lane trend policy metadata, retention and comparison-window defaults, comparison-fingerprint inputs, hotspot limits, and `trend-history.json` artifact contracts aligned to `specs/211-runtime-trend-recalibration/data-model.md`
- [X] T003 [P] Extend `apps/platform/tests/Support/TestLaneReport.php` artifact path, read or write, and staging helpers so `apps/platform/storage/logs/test-lanes/<lane>-latest.trend-history.json` can be published alongside the existing summary, budget, report, and JUnit artifacts
- [X] T004 [P] Update `scripts/platform-test-report` and `scripts/platform-test-artifacts` to discover, select, and hydrate the latest comparable prior bundle or explicit local history input, then export the canonical `trend-history.json` artifact through the existing repo-root wrappers
- [X] T005 [P] Add or update shared guard coverage in `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php`, `apps/platform/tests/Feature/Guards/TestLaneArtifactsContractTest.php`, `apps/platform/tests/Feature/Guards/TestLaneHistoryHydrationContractTest.php`, `apps/platform/tests/Feature/Guards/TestLaneTrendContractSchemaTest.php`, and `apps/platform/tests/Feature/Guards/TestLaneTrendLogicalContractTest.php` to lock lane trend policy metadata, latest-comparable-bundle hydration semantics, JSON schema sync against `specs/211-runtime-trend-recalibration/contracts/test-runtime-trend-history.schema.json`, logical contract sync against `specs/211-runtime-trend-recalibration/contracts/test-runtime-trend.logical.openapi.yaml`, and staged bundle completeness for `trend-history.json`
**Checkpoint**: The shared trend-governance seams are ready for story-specific summary, recalibration, and hotspot work.
---
## Phase 3: User Story 1 - See Lane Drift Before It Becomes A Repeated Gate (Priority: P1) 🎯 MVP
**Goal**: Publish lane-first trend summaries that show current, previous, baseline, budget, and health status before a lane becomes a recurring blocker.
**Independent Test**: Review representative three-sample run sequences for `fast-feedback` and `confidence`, confirm the summary shows current, previous, baseline, and budget values, and verify that healthy, near-budget, worsening, and noisy cases are distinguishable without manual arithmetic.
### Tests for User Story 1
- [X] T006 [P] [US1] Add `apps/platform/tests/Feature/Guards/TestLaneTrendSummaryContractTest.php` and update `apps/platform/tests/Feature/Guards/TestLaneArtifactsContractTest.php` to assert bounded history windows and current, previous, baseline, and budget fields for `fast-feedback` and `confidence`
- [X] T007 [P] [US1] Add `apps/platform/tests/Feature/Guards/TestLaneTrendClassificationTest.php` to cover `healthy`, `budget-near`, `trending-worse`, `regressed`, and `unstable` outcomes, including one-off noisy spike handling
### Implementation for User Story 1
- [X] T008 [US1] Extend `apps/platform/tests/Support/TestLaneReport.php` with `LaneTrendRecord` generation, comparison-window evaluation, comparison fingerprints, and trend-aware `summary.md` plus `report.json` output for `fast-feedback` and `confidence`
- [X] T009 [US1] Update `apps/platform/tests/Support/TestLaneManifest.php`, `.gitea/workflows/test-pr-fast-feedback.yml`, and `.gitea/workflows/test-main-confidence.yml` so pull-request and mainline bundles discover and hydrate the latest comparable history bundle, then republish the refreshed `trend-history.json` artifact without widening lane execution
- [X] T010 [US1] Update `README.md` and `specs/211-runtime-trend-recalibration/quickstart.md` with reviewer guidance and local validation steps for reading lane health summaries across `fast-feedback` and `confidence`
- [X] T011 [US1] Run the narrowest proving path with `./scripts/platform-test-lane fast-feedback`, `./scripts/platform-test-report fast-feedback`, `./scripts/platform-test-lane confidence`, and `./scripts/platform-test-report confidence`, then record representative three-sample `healthy`, `budget-near`, and `unstable` evidence in `specs/211-runtime-trend-recalibration/spec.md` and `specs/211-runtime-trend-recalibration/quickstart.md`
**Checkpoint**: At this point, lane drift visibility for the main contributor lanes should be independently functional and reviewable.
---
## Phase 4: User Story 2 - Decide Recalibration With Evidence Instead Of Habit (Priority: P1)
**Goal**: Separate baseline and budget recalibration from ordinary health status and make every recalibration decision evidence-backed.
**Independent Test**: Review one justified recalibration case and one rejected recalibration case, and confirm the report plus policy make the outcome understandable without private notes.
### Tests for User Story 2
- [X] T012 [P] [US2] Add `apps/platform/tests/Feature/Guards/TestLaneRecalibrationPolicyTest.php` to assert baseline-vs-budget separation, evidence-window requirements, and approved versus rejected rationale handling
- [X] T013 [P] [US2] Add `apps/platform/tests/Feature/Guards/TestLaneRecalibrationEvidenceContractTest.php` to assert candidate, approved, and rejected recalibration records together with explicit summary disclosure for recalibration outcomes
### Implementation for User Story 2
- [X] T014 [US2] Extend `apps/platform/tests/Support/TestLaneBudget.php` with recalibration recommendation helpers, lane-specific tolerance reuse, and explicit baseline plus budget review rules aligned to `specs/211-runtime-trend-recalibration/data-model.md`
- [X] T015 [US2] Extend `apps/platform/tests/Support/TestLaneManifest.php` and `apps/platform/tests/Support/TestLaneReport.php` to emit structured recalibration policy metadata, decision records, evidence run references, and `recordedIn` guidance pointing to `specs/211-runtime-trend-recalibration/spec.md` or the implementation PR without mutating manifest truth automatically
- [X] T016 [US2] Update `README.md` and `specs/211-runtime-trend-recalibration/quickstart.md` with the approved and rejected recalibration policy, required evidence windows, and reviewer follow-up rules
- [X] T017 [US2] Run recalibration validation with `./scripts/platform-test-report fast-feedback` and `./scripts/platform-test-report confidence` against seeded prior histories, then record one approved and one rejected recalibration example in `specs/211-runtime-trend-recalibration/spec.md` and `specs/211-runtime-trend-recalibration/quickstart.md`
**Checkpoint**: At this point, recalibration guidance should be independently testable and clearly separated from ordinary lane health.
---
## Phase 5: User Story 3 - Track Dominant Hotspots Over Time (Priority: P2)
**Goal**: Surface persistent, worsening, and newly dominant hotspots so follow-up optimization work targets the real cost drivers.
**Independent Test**: Review representative hotspot summaries for each primary lane across multiple runs and confirm that persistent, worsening, newly dominant, and unavailable hotspot states are visible.
### Tests for User Story 3
- [X] T018 [P] [US3] Add `apps/platform/tests/Feature/Guards/TestLaneHotspotTrendContractTest.php` to assert top family and file delta output, new or dropped hotspot detection, and explicit unavailable-hotspot disclosure
- [X] T019 [P] [US3] Update `apps/platform/tests/Feature/Guards/ProfileLaneContractTest.php`, `apps/platform/tests/Feature/Guards/FastFeedbackLaneContractTest.php`, `apps/platform/tests/Feature/Guards/ConfidenceLaneContractTest.php`, `apps/platform/tests/Feature/Guards/HeavyGovernanceLaneContractTest.php`, `apps/platform/tests/Feature/Guards/BrowserLaneIsolationTest.php`, and `apps/platform/tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php` to assert support-lane hotspot evidence and hotspot visibility for all primary lanes plus the chosen `junit` or `profiling` support example
### Implementation for User Story 3
- [X] T020 [US3] Extend `apps/platform/tests/Support/TestLaneReport.php` with hotspot delta computation from `classificationTotals`, `familyTotals`, `hotspotFiles`, and `slowestEntries`, capping readable output to the policy limits defined in `apps/platform/tests/Support/TestLaneManifest.php`
- [X] T021 [US3] Update `apps/platform/tests/Support/TestLaneManifest.php`, `.gitea/workflows/test-heavy-governance.yml`, and `.gitea/workflows/test-browser.yml` so heavy and browser bundles retain hotspot-supporting history context and surface missing hotspot evidence explicitly
- [X] T022 [US3] Update `README.md` and `specs/211-runtime-trend-recalibration/quickstart.md` with hotspot investigation guidance, `profiling` and `junit` support-lane usage, and examples of persistent versus newly dominant hotspots
- [X] T023 [US3] Run representative hotspot validation with `./scripts/platform-test-report fast-feedback`, `./scripts/platform-test-report confidence`, `./scripts/platform-test-lane heavy-governance`, `./scripts/platform-test-report heavy-governance`, `./scripts/platform-test-lane browser`, `./scripts/platform-test-report browser`, and one support-lane report path from `./scripts/platform-test-report profiling` or `./scripts/platform-test-report junit`, then record persistent, worsening, newly dominant, and unavailable hotspot evidence for each primary lane in `specs/211-runtime-trend-recalibration/spec.md` and `specs/211-runtime-trend-recalibration/quickstart.md`
**Checkpoint**: At this point, hotspot trend visibility should be independently functional without depending on recalibration rollout evidence.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Validate the full trend-governance slice, record evidence, and finish formatting.
- [X] T024 Run focused Pest coverage for `apps/platform/tests/Feature/Guards/TestLaneTrendSummaryContractTest.php`, `apps/platform/tests/Feature/Guards/TestLaneTrendClassificationTest.php`, `apps/platform/tests/Feature/Guards/TestLaneRecalibrationPolicyTest.php`, `apps/platform/tests/Feature/Guards/TestLaneRecalibrationEvidenceContractTest.php`, `apps/platform/tests/Feature/Guards/TestLaneHotspotTrendContractTest.php`, `apps/platform/tests/Feature/Guards/TestLaneHistoryHydrationContractTest.php`, `apps/platform/tests/Feature/Guards/TestLaneTrendContractSchemaTest.php`, `apps/platform/tests/Feature/Guards/TestLaneTrendLogicalContractTest.php`, `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php`, `apps/platform/tests/Feature/Guards/TestLaneArtifactsContractTest.php`, `apps/platform/tests/Feature/Guards/FastFeedbackLaneContractTest.php`, `apps/platform/tests/Feature/Guards/ConfidenceLaneContractTest.php`, `apps/platform/tests/Feature/Guards/ProfileLaneContractTest.php`, `apps/platform/tests/Feature/Guards/HeavyGovernanceLaneContractTest.php`, `apps/platform/tests/Feature/Guards/BrowserLaneIsolationTest.php`, and `apps/platform/tests/Feature/Guards/CiHeavyBrowserWorkflowContractTest.php` with `cd apps/platform && ./vendor/bin/sail artisan test --compact ...`
- [X] T025 [P] Execute the representative local and Gitea evidence set across `.gitea/workflows/test-pr-fast-feedback.yml`, `.gitea/workflows/test-main-confidence.yml`, `.gitea/workflows/test-heavy-governance.yml`, and `.gitea/workflows/test-browser.yml`, capture at least three sequential comparable samples for each primary lane, include one support-lane example from `junit` or `profiling`, time-box a reviewer dry run to confirm the summary remains decidable within two minutes, and record lane, health class, hotspot availability, recalibration outcome, and any material runtime drift follow-up in `specs/211-runtime-trend-recalibration/spec.md` and `specs/211-runtime-trend-recalibration/quickstart.md`
- [X] T026 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for changes in `apps/platform/tests/Support/TestLaneManifest.php`, `apps/platform/tests/Support/TestLaneBudget.php`, `apps/platform/tests/Support/TestLaneReport.php`, and the new or updated guard tests under `apps/platform/tests/Feature/Guards/`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies and can start immediately.
- **Foundational (Phase 2)**: Depends on Phase 1 and blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on Phase 2 only and is the MVP slice.
- **User Story 2 (Phase 4)**: Depends on Phase 2 and benefits from the trend-history infrastructure completed for User Story 1.
- **User Story 3 (Phase 5)**: Depends on Phase 2 and should follow User Story 1 because hotspot deltas reuse the same history and assessment outputs.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Can begin immediately after Foundational and delivers the first usable runtime-trend surface.
- **User Story 2 (P1)**: Requires the same history contract as User Story 1 but remains independently valuable once that contract exists.
- **User Story 3 (P2)**: Reuses the bounded history from User Story 1 and the policy limits from Foundational, but does not need User Story 2 to be useful.
### Within Each User Story
- Story-specific guard tests should be written and fail before implementation.
- Manifest and wrapper contract changes should be in place before finalizing report output, schema validation, and comparable-bundle hydration steps.
- README and quickstart guidance should land after the corresponding runtime behavior exists.
- Lane validation and evidence capture should complete before closing a story.
### Parallel Opportunities
- T003, T004, and T005 can proceed in parallel once T002 fixes the shared manifest shape.
- In User Story 1, T006 and T007 can run in parallel because they cover separate guard surfaces.
- In User Story 2, T012 and T013 can run in parallel because policy rules and evidence-record assertions are independent tests.
- In User Story 3, T018 and T019 can run in parallel because they touch separate guard suites.
- T025 can run in parallel with final formatting once all implementation and guard work is stable.
---
## Parallel Example: User Story 1
```bash
# After T002-T005 establish the shared history contract, these can proceed in parallel:
Task: "Add apps/platform/tests/Feature/Guards/TestLaneTrendSummaryContractTest.php and update TestLaneArtifactsContractTest.php"
Task: "Add apps/platform/tests/Feature/Guards/TestLaneTrendClassificationTest.php"
```
---
## Parallel Example: User Story 2
```bash
# After User Story 1 exposes comparable history, these can proceed in parallel:
Task: "Add apps/platform/tests/Feature/Guards/TestLaneRecalibrationPolicyTest.php"
Task: "Add apps/platform/tests/Feature/Guards/TestLaneRecalibrationEvidenceContractTest.php"
```
---
## Parallel Example: User Story 3
```bash
# After the shared hotspot-ready report shape exists, these can proceed in parallel:
Task: "Add apps/platform/tests/Feature/Guards/TestLaneHotspotTrendContractTest.php"
Task: "Update apps/platform/tests/Feature/Guards/ProfileLaneContractTest.php and apps/platform/tests/Feature/Guards/HeavyGovernanceLaneContractTest.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate `fast-feedback` and `confidence` trend summaries independently before continuing.
### Incremental Delivery
1. Deliver bounded history and lane health summaries first.
2. Add explicit recalibration policy and evidence records next.
3. Add hotspot delta visibility for heavy, browser, and support-lane-assisted investigations last.
4. Finish with focused guard validation, real evidence capture, and formatting.
### Parallel Team Strategy
1. One contributor can extend `apps/platform/tests/Support/TestLaneManifest.php` and wrapper scripts while another prepares the new guard suites.
2. After Foundational completes, User Story 1 test work and workflow hydration changes can be split across contributors.
3. User Story 2 recalibration logic and User Story 3 hotspot logic can proceed separately once the history contract is stable.
---
## Notes
- `[P]` tasks operate on different files or independent guard suites and can run in parallel once dependencies are satisfied.
- `[US1]`, `[US2]`, and `[US3]` map tasks directly to the user stories in `spec.md`.
- This feature changes runtime-governance behavior, so the narrowest relevant lane reruns and evidence capture remain part of the definition of done.
- Live Gitea validation remains required because local wrapper tests alone cannot prove cross-run artifact hydration and uploaded bundle behavior.

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Test Suite Authoring Constitution & Review Guardrails
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-18
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validation completed in one iteration.
- No unresolved clarification markers or template placeholders remain.
- The spec stays repository-process focused and aligns with the existing test-governance chain from Specs 206 through 211.

View File

@ -0,0 +1,302 @@
openapi: 3.1.0
info:
title: Test Authoring Constitution & Review Guardrails
version: 1.0.0
description: |
Logical contract for the repository-owned authoring and review workflow
introduced by Spec 212. This documents constitution, template, checklist,
and escalation semantics. It is not a public HTTP API.
servers:
- url: https://tenantatlas.local/logical
paths:
/logical/test-governance/spec-impact/validate:
post:
summary: Validate one spec-level testing and lane impact block
operationId: validateSpecImpactBlock
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SpecImpactValidationRequest'
responses:
'200':
description: Spec impact block evaluated
content:
application/json:
schema:
$ref: '#/components/schemas/SpecImpactValidationResult'
/logical/test-governance/plan-impact/validate:
post:
summary: Validate one planning-time test-governance block
operationId: validatePlanImpactBlock
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlanImpactValidationRequest'
responses:
'200':
description: Plan impact block evaluated
content:
application/json:
schema:
$ref: '#/components/schemas/PlanImpactValidationResult'
/logical/test-governance/tasks/checklist/evaluate:
post:
summary: Evaluate whether a task checklist keeps test-governance obligations visible
operationId: evaluateTaskGovernanceChecklist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TaskChecklistEvaluationRequest'
responses:
'200':
description: Task-level governance checklist evaluation returned
content:
application/json:
schema:
$ref: '#/components/schemas/TaskChecklistEvaluationResult'
/logical/test-governance/reviews/escalation-assessment:
post:
summary: Assess whether a test change requires governance escalation
operationId: assessReviewEscalation
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EscalationAssessmentRequest'
responses:
'200':
description: Escalation assessment returned
content:
application/json:
schema:
$ref: '#/components/schemas/EscalationAssessmentResult'
/logical/test-governance/guidance:
get:
summary: Read the contributor guidance pack for test authoring decisions
operationId: readContributorGuidance
responses:
'200':
description: Contributor guidance returned
content:
application/json:
schema:
$ref: '#/components/schemas/ContributorGuidancePack'
components:
schemas:
SpecImpactValidationRequest:
type: object
additionalProperties: false
required:
- specPath
- validationLanes
- testFamilyImpact
- heavySurfaceImpact
- fixtureCostImpact
- reviewValidationCommand
properties:
specPath:
type: string
validationLanes:
oneOf:
- type: string
enum:
- N/A
- type: array
items:
type: string
enum:
- fast-feedback
- confidence
- heavy-governance
- browser
- profiling
- junit
testFamilyImpact:
type: string
heavySurfaceImpact:
type: string
fixtureCostImpact:
type: string
budgetTrendImpact:
type: string
reviewValidationCommand:
type: string
escalationNeeded:
type: boolean
SpecImpactValidationResult:
type: object
additionalProperties: false
required:
- status
- findings
properties:
status:
type: string
enum:
- complete
- needs-revision
findings:
type: array
items:
type: string
reviewerHandOff:
type: string
PlanImpactValidationRequest:
type: object
additionalProperties: false
required:
- planPath
- changedTestTypes
- helperFixtureImpact
- laneReshapeImpact
- closingValidation
properties:
planPath:
type: string
changedTestTypes:
type: array
items:
type: string
helperFixtureImpact:
type: string
laneReshapeImpact:
type: string
closingValidation:
type: string
driftDocumentationTarget:
type: string
PlanImpactValidationResult:
type: object
additionalProperties: false
required:
- status
- findings
properties:
status:
type: string
enum:
- complete
- needs-revision
findings:
type: array
items:
type: string
taskChecklistRequirements:
type: array
items:
type: string
TaskChecklistEvaluationRequest:
type: object
additionalProperties: false
required:
- checklistId
- items
- runtimeChange
properties:
checklistId:
type: string
items:
type: array
items:
type: string
runtimeChange:
type: boolean
evidenceTarget:
type: string
TaskChecklistEvaluationResult:
type: object
additionalProperties: false
required:
- status
- missingCoverage
properties:
status:
type: string
enum:
- complete
- incomplete
missingCoverage:
type: array
items:
type: string
notes:
type: array
items:
type: string
EscalationAssessmentRequest:
type: object
additionalProperties: false
required:
- changeRef
- triggers
properties:
changeRef:
type: string
triggers:
type: array
items:
type: string
enum:
- new-heavy-family
- new-browser-coverage
- material-lane-cost-shift
- broad-filament-livewire-governance-surface
- revived-expensive-default
- budget-or-baseline-relevant-change
- major-suite-reshaping
contextNote:
type: string
EscalationAssessmentResult:
type: object
additionalProperties: false
required:
- outcome
- reason
properties:
outcome:
type: string
enum:
- none
- document-in-feature
- follow-up-spec
- reject-or-split
reason:
type: string
recordLocation:
type:
- string
- 'null'
ContributorGuidancePack:
type: object
additionalProperties: false
required:
- guidanceId
- decisionPoints
- entryPoints
- sharedVocabulary
properties:
guidanceId:
type: string
decisionPoints:
type: array
items:
type: string
examplePatterns:
type: array
items:
type: string
entryPoints:
type: array
items:
type: string
sharedVocabulary:
type: array
items:
type: string

View File

@ -0,0 +1,304 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://tenantatlas.local/specs/212/test-authoring-governance.schema.json",
"title": "Test Authoring Governance Pack",
"description": "Repository-owned contract for the constitution, authoring prompts, review guardrails, escalation policy, and validation scenarios introduced by Spec 212.",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"constitutionSection",
"specImpactPromptBlock",
"planImpactPromptBlock",
"taskGovernanceChecklist",
"reviewGuardrailChecklist",
"escalationPolicy",
"contributorGuidance",
"validationScenarios"
],
"properties": {
"schemaVersion": {
"type": "string"
},
"constitutionSection": {
"$ref": "#/$defs/constitutionSection"
},
"specImpactPromptBlock": {
"$ref": "#/$defs/specImpactPromptBlock"
},
"planImpactPromptBlock": {
"$ref": "#/$defs/planImpactPromptBlock"
},
"taskGovernanceChecklist": {
"$ref": "#/$defs/checklist"
},
"reviewGuardrailChecklist": {
"$ref": "#/$defs/reviewChecklist"
},
"escalationPolicy": {
"$ref": "#/$defs/escalationPolicy"
},
"contributorGuidance": {
"$ref": "#/$defs/contributorGuidance"
},
"validationScenarios": {
"type": "array",
"minItems": 2,
"items": {
"$ref": "#/$defs/validationScenario"
}
}
},
"$defs": {
"constitutionSection": {
"type": "object",
"additionalProperties": false,
"required": [
"sectionId",
"version",
"classificationRule",
"laneAwarenessRule",
"heavyJustificationRule",
"minimalFixtureRule",
"expensiveDefaultBanRule",
"reviewExpectationRule",
"escalationRule",
"linkedWorkflowSurfaces"
],
"properties": {
"sectionId": { "type": "string" },
"version": { "type": "string" },
"classificationRule": { "type": "string" },
"laneAwarenessRule": { "type": "string" },
"heavyJustificationRule": { "type": "string" },
"minimalFixtureRule": { "type": "string" },
"expensiveDefaultBanRule": { "type": "string" },
"reviewExpectationRule": { "type": "string" },
"escalationRule": { "type": "string" },
"linkedWorkflowSurfaces": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
}
}
},
"specImpactPromptBlock": {
"type": "object",
"additionalProperties": false,
"required": [
"blockId",
"requiredFields",
"narrowestProofRule",
"naAllowanceRule",
"escalationPrompt",
"reviewerHandOff"
],
"properties": {
"blockId": { "type": "string" },
"requiredFields": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
},
"narrowestProofRule": { "type": "string" },
"naAllowanceRule": { "type": "string" },
"escalationPrompt": { "type": "string" },
"reviewerHandOff": { "type": "string" }
}
},
"planImpactPromptBlock": {
"type": "object",
"additionalProperties": false,
"required": [
"blockId",
"changedTestTypes",
"helperOrFixtureImpact",
"laneReshapeQuestion",
"closingValidationRule",
"driftDocumentationRule"
],
"properties": {
"blockId": { "type": "string" },
"changedTestTypes": {
"type": "array",
"items": { "type": "string" }
},
"helperOrFixtureImpact": { "type": "string" },
"laneReshapeQuestion": { "type": "string" },
"closingValidationRule": { "type": "string" },
"driftDocumentationRule": { "type": "string" }
}
},
"checklist": {
"type": "object",
"additionalProperties": false,
"required": [
"checklistId",
"items",
"appliesWhen",
"evidenceTarget"
],
"properties": {
"checklistId": { "type": "string" },
"items": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
},
"appliesWhen": { "type": "string" },
"evidenceTarget": { "type": "string" }
}
},
"reviewChecklist": {
"type": "object",
"additionalProperties": false,
"required": [
"checklistId",
"questions",
"expectedOutcomeSet",
"maxReviewMinutes",
"escalationReference"
],
"properties": {
"checklistId": { "type": "string" },
"questions": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
},
"expectedOutcomeSet": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"keep",
"split",
"document-in-feature",
"follow-up-spec",
"reject-or-split"
]
}
},
"maxReviewMinutes": {
"type": "integer",
"minimum": 1
},
"escalationReference": { "type": "string" }
}
},
"escalationPolicy": {
"type": "object",
"additionalProperties": false,
"required": [
"policyId",
"triggers",
"outcomes",
"followUpThresholdRule"
],
"properties": {
"policyId": { "type": "string" },
"triggers": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"new-heavy-family",
"new-browser-coverage",
"material-lane-cost-shift",
"broad-filament-livewire-governance-surface",
"revived-expensive-default",
"budget-or-baseline-relevant-change",
"major-suite-reshaping"
]
}
},
"outcomes": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"enum": [
"none",
"document-in-feature",
"follow-up-spec",
"reject-or-split"
]
}
},
"followUpThresholdRule": { "type": "string" }
}
},
"contributorGuidance": {
"type": "object",
"additionalProperties": false,
"required": [
"guidanceId",
"decisionPoints",
"examplePatterns",
"entryPoints",
"sharedVocabulary"
],
"properties": {
"guidanceId": { "type": "string" },
"decisionPoints": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
},
"examplePatterns": {
"type": "array",
"items": { "type": "string" }
},
"entryPoints": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
},
"sharedVocabulary": {
"type": "array",
"minItems": 1,
"items": { "type": "string" }
}
}
},
"validationScenario": {
"type": "object",
"additionalProperties": false,
"required": [
"scenarioId",
"scenarioType",
"representativeArtifact",
"expectedPromptPattern",
"expectedEscalationOutcome",
"status"
],
"properties": {
"scenarioId": { "type": "string" },
"scenarioType": {
"type": "string",
"enum": ["low-impact", "high-impact"]
},
"representativeArtifact": { "type": "string" },
"expectedPromptPattern": { "type": "string" },
"expectedEscalationOutcome": {
"type": "string",
"enum": [
"none",
"document-in-feature",
"follow-up-spec",
"reject-or-split"
]
},
"status": {
"type": "string",
"enum": ["planned", "validated", "needs-tuning"]
},
"notes": {
"type": "string"
}
}
}
}
}

View File

@ -0,0 +1,178 @@
# Data Model: Test Suite Authoring Constitution & Review Guardrails
This feature adds repository-owned governance artifacts only. It does not add product database tables or runtime-owned entities. All objects below are implemented as constitution text, markdown prompt blocks, checklists, logical contracts, or validation notes.
## 1. TestAuthoringConstitutionSection
**Purpose**: Defines the standing rules contributors and reviewers must follow when new tests are introduced or existing tests expand in cost.
| Field | Type | Description |
|-------|------|-------------|
| `sectionId` | string | Stable identifier for the constitution section. |
| `version` | string | Version of the rule set. |
| `scope` | string | Repository workflow scope, always `workspace`. |
| `classificationRule` | string | Requires explicit classification of new or changed tests. |
| `laneAwarenessRule` | string | Requires authors to name affected lane or lanes. |
| `heavyJustificationRule` | string | Requires justification for database, Livewire, Filament, or browser use. |
| `minimalFixtureRule` | string | States that minimal fixtures and cheap defaults are the norm. |
| `expensiveDefaultBanRule` | string | Forbids hidden shared helper, factory, or seed cost growth without disclosure or escalation. |
| `reviewExpectationRule` | string | Requires the reviewer guardrail questions to be applied when tests change. |
| `escalationRule` | string | Defines when a change must be documented locally or raised as follow-up governance work. |
| `linkedWorkflowSurfaces` | array | Template, checklist, and contributor-doc surfaces that must remain aligned with the section. |
**Relationships**
- One `TestAuthoringConstitutionSection` governs one `SpecImpactPromptBlock`, one `PlanImpactPromptBlock`, one `TaskGovernanceChecklist`, one `ReviewGuardrailChecklist`, and one `ContributorGuidancePack`.
**Validation Rules**
- The rule set must stay short enough to be quoted or understood during routine authoring and review.
- The section must reuse existing lane vocabulary from Specs 206 through 211.
- The section must not invent new validation lanes or new runtime governance subsystems.
## 2. SpecImpactPromptBlock
**Purpose**: Defines the authoring-time questions every spec must answer about test, lane, and runtime cost impact.
| Field | Type | Description |
|-------|------|-------------|
| `blockId` | string | Stable identifier for the spec prompt block. |
| `requiredFields` | array | Required answers such as affected lanes, test-family impact, heavy-surface relevance, fixture-cost impact, budget or trend implications, and reviewer validation commands or `N/A`. |
| `narrowestProofRule` | string | Requires authors to name the narrowest sufficient validation path when runtime changes exist. |
| `naAllowanceRule` | string | Allows concise `N/A` or `none` answers for docs-only or low-impact work. |
| `escalationPrompt` | string | Direct question asking whether the change creates a new heavy family, new browser scope, or material lane-cost shift. |
| `reviewerHandOff` | string | States what reviewers should verify from the completed block. |
**Validation Rules**
- The block must be short enough for ordinary specs to complete quickly.
- The block must distinguish between “no impact” and “impact exists but is acceptable.”
- The block must not duplicate entire review checklist content; it only prepares the review handoff.
## 3. PlanImpactPromptBlock
**Purpose**: Defines the planning-time questions that convert the spec's declared impact into implementation-time guardrails.
| Field | Type | Description |
|-------|------|-------------|
| `blockId` | string | Stable identifier for the plan prompt block. |
| `changedTestTypes` | array | Test types being added or changed. |
| `helperOrFixtureImpact` | string | Whether helpers, factories, seeds, or defaults widen. |
| `laneReshapeQuestion` | string | Whether lane movement, heavy-family addition, or browser promotion is implicated. |
| `closingValidationRule` | string | Defines the minimum validation evidence to finish the feature. |
| `driftDocumentationRule` | string | States where material runtime drift or recalibration follow-up must be recorded. |
**Relationships**
- One `PlanImpactPromptBlock` operationalizes one `SpecImpactPromptBlock`.
- One `PlanImpactPromptBlock` informs one `TaskGovernanceChecklist`.
**Validation Rules**
- The block must make authoring decisions actionable in tasks, not merely restate the spec.
- The block must expose helper or fixture widening even when the local feature is otherwise small.
## 4. TaskGovernanceChecklist
**Purpose**: Provides a short implementation-time checklist that keeps lane fit, setup cost, and validation visible while tasks are broken down.
| Field | Type | Description |
|-------|------|-------------|
| `checklistId` | string | Stable identifier for the task checklist. |
| `items` | array | Required checks such as lane assignment confirmed, no unnecessary heavy cost, minimal fixtures used, relevant validation planned, and budget or trend notes recorded when needed. |
| `appliesWhen` | string | Scope rule for runtime-changing work versus docs-only work. |
| `evidenceTarget` | string | Where the resulting note or evidence must be recorded. |
**Validation Rules**
- The checklist must remain short enough to fit inside ordinary task planning.
- The checklist must not require runtime-lane execution for docs-only work.
## 5. ReviewGuardrailChecklist
**Purpose**: Gives reviewers a fast, repeatable decision aid for new or changed tests.
| Field | Type | Description |
|-------|------|-------------|
| `checklistId` | string | Stable identifier for the review checklist. |
| `questions` | array | Direct questions about lane fit, breadth, DB or UI-heavy necessity, setup cost, split need, escalation need, and budget or trend notes. |
| `expectedOutcomeSet` | array | Allowed reviewer outcomes such as `keep`, `split`, `document-local`, `follow-up-spec`, or `reject-drift`. |
| `maxReviewMinutes` | integer | Target application time for one representative change. |
| `escalationReference` | string | Link or pointer to the escalation policy used when a trigger is present. |
**Validation Rules**
- Questions must be phrased as decisions, not vague advice.
- The checklist must stay usable in under 3 minutes for a representative diff.
- The checklist must support both low-impact and high-impact changes.
## 6. EscalationAssessment
**Purpose**: Captures whether a change is ordinary test maintenance or a governance-significant event requiring extra documentation or follow-up.
| Field | Type | Description |
|-------|------|-------------|
| `assessmentId` | string | Stable identifier for one escalation assessment. |
| `triggerSet` | array | Detected triggers such as new heavy family, new browser scope, revived expensive defaults, material lane-cost shift, or broad suite reshaping. |
| `outcome` | enum | `none`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`. |
| `reason` | string | Human-readable explanation of why the outcome was chosen. |
| `recordLocation` | string | Active spec path or implementation PR location where the outcome is recorded. |
| `examples` | array | Example changes that should resolve to this outcome. |
**Validation Rules**
- Every trigger must map to a documented action.
- `follow-up-spec` is reserved for recurring pain or structural change, not ordinary recalibration.
- `none` is valid only when the change stays inside an existing lane and family without hidden-cost growth.
## 7. ContributorGuidancePack
**Purpose**: Gives contributors concise operational guidance for choosing the smallest justified test surface and recognizing escalation signals.
| Field | Type | Description |
|-------|------|-------------|
| `guidanceId` | string | Stable identifier for the contributor guidance pack. |
| `decisionPoints` | array | High-value decisions such as unit vs feature vs heavy vs browser, when DB is justified, and when a test is too broad. |
| `examplePatterns` | array | Brief examples of acceptable `N/A`, lane-specific justification, and escalation-worthy changes. |
| `entryPoints` | array | Documentation surfaces where the guidance appears. |
| `sharedVocabulary` | array | Canonical governance terms reused across constitution, templates, and review. |
**Validation Rules**
- Guidance must stay short and operational.
- Guidance must avoid duplicating long prose across multiple files.
- Guidance must reflect the same vocabulary used in the constitution and review checklist.
## 8. ValidationScenario
**Purpose**: Represents one dry-run scenario used to prove that the authoring and review workflow stays usable.
| Field | Type | Description |
|-------|------|-------------|
| `scenarioId` | string | Stable scenario identifier. |
| `scenarioType` | enum | `low-impact` or `high-impact`. |
| `representativeArtifact` | string | Spec, plan, or diff used in the dry run. |
| `expectedPromptPattern` | string | Expected answer style, such as `N/A` or a multi-lane justification. |
| `expectedEscalationOutcome` | string | Expected escalation result for the scenario. |
| `status` | enum | `planned`, `validated`, or `needs-tuning`. |
| `notes` | string | What the dry run proved or what wording needs refinement. |
**Validation Rules**
- At least one `low-impact` and one `high-impact` scenario must be validated.
- `low-impact` scenarios must prove the workflow stays lightweight.
- `high-impact` scenarios must prove the escalation prompts catch the intended cost-center changes.
## State Transitions
### EscalationAssessment.outcome
- `none` -> `document-in-feature`: allowed when a review reveals governance-relevant cost or scope that should be explicitly recorded but does not justify a new spec.
- `document-in-feature` -> `follow-up-spec`: allowed when the discovered issue reflects recurring pain or structural lane change rather than one contained feature decision.
- Any state -> `reject-or-split`: allowed when the change is too broad, too hidden in cost, or insufficiently justified to merge as proposed.
### ValidationScenario.status
- `planned` -> `validated`: allowed when the scenario can be completed with the expected prompt pattern and escalation outcome.
- `planned` -> `needs-tuning`: allowed when wording or checklist structure creates unnecessary friction or misses the expected governance signal.
- `needs-tuning` -> `validated`: allowed after the relevant constitution, template, or checklist wording is refined.

View File

@ -0,0 +1,165 @@
# Implementation Plan: Test Suite Authoring Constitution & Review Guardrails
**Branch**: `212-test-authoring-guardrails` | **Date**: 2026-04-18 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/212-test-authoring-guardrails/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/212-test-authoring-guardrails/spec.md`
## Summary
Implement Spec 212 by tightening the existing `TEST-GOV-001` workflow surfaces in the constitution, SpecKit templates, and contributor-facing repository guidance so new tests must declare lane impact, justify heavy setup, trigger explicit escalation when new cost centers appear, and give reviewers a fast decision-grade checklist without introducing runtime tooling, bots, or a second governance subsystem.
## Technical Context
**Language/Version**: Markdown for repository governance artifacts, JSON Schema plus logical OpenAPI for planning contracts, and Bash-backed SpecKit scripts already present in the repo
**Primary Dependencies**: `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and the existing Specs 206 through 211 governance vocabulary
**Storage**: Repository-owned markdown and contract artifacts under `.specify/`, `specs/212-test-authoring-guardrails/`, and root documentation files; no product database persistence
**Testing**: Document and template validation against representative low-impact and higher-cost feature flows, checklist completeness review, and no runtime Pest lane execution because the feature is docs and workflow only
**Validation Lanes**: `N/A`
**Target Platform**: TenantAtlas monorepo with SpecKit-driven specification workflow, repository contributor guidance, and Gitea-backed code review
**Project Type**: Monorepo with a Laravel platform app and Astro website, but this feature is scoped strictly to repository governance and authoring workflow artifacts
**Performance Goals**: Keep low-impact feature answers to the new prompts completable in under 1 minute, keep representative review-guardrail application under 3 minutes, and avoid adding any new daily workflow surface beyond the existing constitution, templates, and contributor guidance entry points
**Constraints**: No new runtime dependencies, no CI bot requirement, no new product routes or persistence, no contradiction with Specs 206 through 211, no speculative governance framework, and no new documentation sprawl when an existing entry point can carry the guidance
**Scale/Scope**: One constitution section, four SpecKit templates, two contributor-facing guidance surfaces, one review-guardrail surface, one escalation policy set, one contributor guidance pack, and validation against at least two representative spec flows
### Filament v5 Implementation Notes
- **Livewire v4.0+ compliance**: Preserved. This feature only changes repository authoring and review artifacts and does not alter the Filament or Livewire runtime stack.
- **Provider registration location**: Unchanged. Existing panel providers remain registered in `bootstrap/providers.php`.
- **Global search rule**: No globally searchable resources are added or modified.
- **Destructive actions**: No runtime destructive actions are introduced. Existing confirmation and authorization behavior remain unchanged.
- **Asset strategy**: No panel or shared assets are added. Existing `filament:assets` deployment behavior remains unchanged.
- **Testing plan**: Validate the constitution, template prompts, checklist wording, escalation semantics, and contributor guidance against one docs-only `N/A` path and one higher-cost governed spec path; no runtime UI, action, or Livewire tests are added by this feature.
## Test Governance Check
- **Affected validation lanes**: `N/A`
- **Narrowest proving command(s)**: `N/A`. Validation is document and workflow based rather than runtime-lane based.
- **Fixture / helper cost risks**: None directly. The feature exists to prevent future hidden helper and fixture cost growth rather than to introduce new shared setup.
- **Heavy-family additions or promotions**: None. The intended change is earlier disclosure and escalation of heavy-family growth in future work.
- **Budget / baseline / trend follow-up**: None directly. The feature must stay consistent with current lane, budget, and trend vocabulary without mutating those contracts.
- **Why no dedicated follow-up spec is needed**: Spec 212 is itself the structural authoring and review guardrail feature. After rollout, routine upkeep should live inside ordinary feature specs unless recurring pain or another structural lane-model change appears.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS. No inventory, backup, or snapshot product truth changes.
- Read/write separation: PASS. This is repository-only governance work with no end-user mutations.
- Graph contract path: PASS. No Microsoft Graph calls or contract-registry changes.
- Deterministic capabilities: PASS. No capability resolver or authorization registry changes.
- RBAC-UX, workspace isolation, tenant isolation: PASS. No runtime routes, policies, or scope behavior change.
- Run observability and Ops-UX: PASS. No `OperationRun` or monitoring lifecycle changes.
- Data minimization: PASS. The new artifacts are repository-owned prompts and guidance only.
- Test governance (TEST-GOV-001): PASS WITH WORK. The feature intentionally strengthens authoring-time and review-time enforcement of lane choice, fixture-cost disclosure, heavy-family escalation, and runtime-drift documentation.
- Proportionality and bloat control: PASS WITH LIMITS. The implementation may touch several workflow entry points, but it must do so by sharpening existing sections rather than creating a new governance framework, parallel handbook, or automation layer.
- TEST-TRUTH-001: PASS WITH WORK. The added prompts and checklists must stay tied to real lane, cost, and escalation decisions instead of inventing abstract process overhead.
- Filament/UI constitutions: PASS / NOT APPLICABLE. No operator-facing runtime UI, action surfaces, or panels are changed.
**Phase 0 Gate Result**: PASS
- The feature stays bounded to repository constitution, templates, review prompts, and contributor guidance.
- No new product persistence, Graph seams, runtime routes, or authorization planes are introduced.
- The plan reuses existing `TEST-GOV-001` workflow surfaces instead of inventing a second governance mechanism.
## Project Structure
### Documentation (this feature)
```text
specs/212-test-authoring-guardrails/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ ├── test-authoring-governance.schema.json
│ └── test-authoring-governance.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
.specify/
├── memory/
│ └── constitution.md
├── templates/
│ ├── checklist-template.md
│ ├── plan-template.md
│ ├── spec-template.md
│ └── tasks-template.md
└── README.md
README.md
specs/
└── 212-test-authoring-guardrails/
├── spec.md
└── checklists/requirements.md
```
**Structure Decision**: Keep all changes inside the existing constitution, SpecKit templates, and established contributor documentation entry points so the governance model becomes more explicit without creating a separate handbook, reviewer-only subsystem, or new runtime-owned code surface.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | Not applicable | Not applicable |
## Proportionality Review
- **Current operator problem**: Contributors can still introduce broad, expensive, or misclassified tests before review, and reviewers still lack one compact, repeatable checklist for catching accidental heavy cost and escalation triggers before merge.
- **Existing structure is insufficient because**: `TEST-GOV-001` and the current templates already mention lane/runtime impact, but they do not yet fully encode authoring-time classification discipline, a stable review checklist, escalation triggers, or lightweight contributor decision guidance.
- **Narrowest correct implementation**: Tighten the existing constitution, templates, and contributor docs with a small number of mandatory prompts and review questions instead of adding bots, runtime policy engines, or a standalone governance manual.
- **Ownership cost created**: The repo must maintain a concise shared vocabulary for escalation, authoring prompts, and review guardrails across constitution, template, and contributor docs.
- **Alternative intentionally rejected**: A new automation bot, PR scoring system, or separate governance handbook, because each would add process surface or drift risk beyond what the current delivery workflow needs.
- **Release truth**: Current-release repository truth needed to make the test-governance chain from Specs 206 through 211 durable in day-to-day authoring and review.
## Phase 0 — Research (complete)
- Output: [research.md](./research.md)
- Resolved key decisions:
- Reuse and sharpen the existing `TEST-GOV-001` workflow surfaces instead of creating a new governance subsystem.
- Keep contributor guidance in existing high-traffic documentation surfaces unless a new file proves necessary for clarity.
- Model review guardrails as a short question set and explicit escalation outcomes rather than a lengthy rubric or approval board.
- Treat escalation as a documented authoring and review decision, not a new automatic CI blocker.
- Validate the workflow with one docs-only `N/A` path and one higher-cost governed-spec path so both minimal overhead and escalation behavior are proven.
- Use logical contract artifacts to describe the expected prompt, checklist, and escalation semantics even though the feature adds no transport API, while treating those files as plan-time scaffolding rather than new maintained workflow surfaces.
## Phase 1 — Design & Contracts (complete)
- Output: [data-model.md](./data-model.md) formalizes the repository-owned governance objects: constitution rule set, spec and plan prompt blocks, task checklist, review checklist, escalation assessment, contributor guidance pack, and validation scenarios.
- Output: [contracts/test-authoring-governance.schema.json](./contracts/test-authoring-governance.schema.json) defines schema-first planning scaffolding for the governance pack the workflow must express; it is not an additional maintained reviewer-facing surface.
- Output: [contracts/test-authoring-governance.logical.openapi.yaml](./contracts/test-authoring-governance.logical.openapi.yaml) captures logical planning semantics for validating spec and plan impact blocks, evaluating a task checklist, assessing escalation, and serving contributor guidance; it is not an additional maintained reviewer-facing surface.
- Output: [quickstart.md](./quickstart.md) provides the implementation order, representative validation flows, and rollout checklist.
### Post-design Constitution Re-check
- PASS: No runtime routes, panels, Graph seams, or authorization planes are introduced.
- PASS: The design keeps all new truth repository-owned and documentation-first.
- PASS: The workflow surfaces stay inside existing constitution, template, and contributor entry points rather than creating a new process framework.
- PASS WITH WORK: Review guardrails and escalation wording must remain concise enough that low-impact features can still answer with `N/A` or `none` without friction.
- PASS WITH WORK: Any contributor guidance added to `README.md` or `.specify/README.md` must avoid duplicating the same rules in multiple long prose blocks that will drift.
## Phase 2 — Implementation Planning
`tasks.md` should cover:
- Auditing the current `TEST-GOV-001` constitution text, SpecKit templates, and contributor docs to isolate exactly which authoring-time and review-time gaps remain after Specs 206 through 211.
- Updating `.specify/memory/constitution.md` with a short, binding test authoring and review guardrail section that makes classification, minimal fixtures, explicit heavy justification, escalation triggers, and expensive-default bans unmistakable.
- Updating `.specify/templates/spec-template.md` so the existing `Testing / Lane / Runtime Impact` block explicitly asks for lane fit, heavy-surface justification, fixture-cost disclosure, and minimal reviewer validation in authoring-time language.
- Updating `.specify/templates/plan-template.md` so the `Test Governance Check` and technical planning surfaces make test type changes, helper widening, lane reshaping, escalation triggers, and closing validation explicit before implementation begins.
- Updating `.specify/templates/tasks-template.md` to standardize a short task-level governance checklist covering lane assignment, minimal setup, relevant validation, hidden-cost prevention, and documentation of material budget or trend impact.
- Updating `.specify/templates/checklist-template.md` as the canonical generated review-checklist surface, with `.specify/README.md` as the reviewer entry point, so reviewers get a stable, quick guardrail checklist with direct keep, split, or escalate questions.
- Updating `.specify/README.md` and `README.md` with concise contributor guidance showing how to answer `N/A` for low-impact work, when database or UI-heavy coverage is justified, and when a new heavy family or browser path requires escalation.
- Validating the updated workflow against one low-impact docs or template scenario and one higher-cost governed-spec scenario, confirming that the low-impact path stays fast and the higher-cost path surfaces the intended escalation questions.
- Recording the validation note inside the active spec or implementation PR so the workflow proof is durable and does not live only in casual commentary.
### Contract Implementation Note
- The JSON schema is repository-tooling oriented and describes the complete governance pack the repo must express during planning even if the first implementation lives mostly in markdown templates and checklists.
- The OpenAPI file is logical rather than transport-prescriptive. It documents workflow semantics for authoring and review interactions, not a public HTTP API.
- The design intentionally avoids new runtime services or CI bots. The contracts are plan-time alignment aids inside this spec set, not new long-term reviewer-facing workflow surfaces that must evolve independently from the markdown sources.
### Deployment Sequencing Note
- No database migration is planned.
- No asset publish step changes.
- Recommended rollout order: tighten constitution text first, then update spec and plan templates, then update task and review checklist surfaces, then update contributor guidance, then validate low-impact and higher-cost scenarios, and finally note any wording refinements needed to keep the process lightweight.

View File

@ -0,0 +1,128 @@
# Quickstart: Test Suite Authoring Constitution & Review Guardrails
This feature is repository-governance only. It does not change application runtime behavior, validation lanes, or deployment infrastructure. The goal is to tighten the authoring and review workflow so future test changes declare cost and escalation signals earlier.
## 1. Confirm the implementation surfaces
Review the files that already carry test-governance workflow truth:
- `.specify/memory/constitution.md`
- `.specify/templates/spec-template.md`
- `.specify/templates/plan-template.md`
- `.specify/templates/tasks-template.md`
- `.specify/templates/checklist-template.md`
- `.specify/README.md`
- `README.md`
Do not create a parallel handbook unless one of these surfaces cannot carry the needed guidance cleanly.
## 2. Tighten the constitution first
Update the constitution so the standing rules are explicit about:
- authoring-time classification of new and changed tests
- naming affected lanes when runtime behavior or tests change
- justifying database, Livewire, Filament, or browser usage
- keeping fixtures, helpers, factories, and seeds cheap by default
- escalating new heavy families, new browser scope, revived expensive defaults, or material lane-cost shifts
- giving reviewers a stable decision-grade checklist target
Keep the language short and binding.
## 3. Update the template surfaces in order
Apply the same vocabulary consistently across the authoring workflow:
1. `spec-template.md`: strengthen the existing `Testing / Lane / Runtime Impact` block with authoring-time classification and escalation prompts.
2. `plan-template.md`: strengthen the `Test Governance Check` so helper widening, lane reshaping, and closing validation are explicit before implementation.
3. `tasks-template.md`: standardize the short task-level governance checklist.
4. `checklist-template.md` as the canonical generated review-checklist surface, with `.specify/README.md` as the reviewer entry point: provide the fixed review guardrail questions and expected escalation outcomes.
Avoid asking the same question in three different ways across the templates.
## 4. Update contributor guidance
Keep the contributor-facing explanation concise and practical:
- how to answer `N/A` or `none` for low-impact work
- how to choose between unit, feature, heavy-governance, and browser coverage
- when database or UI-heavy coverage is justified
- when a test has become too broad
- when to extend an existing family versus introduce a new one
- when a change stays local versus needs escalation
Prefer updating `.specify/README.md` and `README.md` over adding a new long-lived documentation file.
## 5. Run the two required dry runs
### Low-impact validation path
Use a genuinely low-impact template-only or docs-only change, such as a change limited to `.specify/templates/checklist-template.md` and `.specify/README.md`, to prove that:
- the spec prompt can be completed with `N/A` or `none`
- the plan prompt does not demand runtime lanes
- the review checklist stays brief and still ends with an explicit `keep` outcome without forcing fake escalation
Expected authoring answers:
- affected validation lanes: `N/A`
- test purpose / family impact: `none`
- DB / Livewire / Filament / browser usage: `none`
- fixture / helper / factory / seed / context cost impact: `none`
- escalation triggers and outcome: `none`
Expected result: the workflow remains lightweight and completable in under 1 minute for the authoring prompts.
### Canonical review-checklist surface
The generated checklist based on `.specify/templates/checklist-template.md` should ask exactly these decision-grade questions:
- Is the declared validation lane the narrowest lane or lane mix that proves the change?
- Does the test stay in the smallest honest family (`Unit`, `Feature`, `Heavy-Governance`, `Browser`)?
- Is the changed or added test no broader than the behavior it proves?
- Is any database, Livewire, Filament, or browser surface justified over a narrower alternative?
- Do shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default?
- Is the minimal reviewer validation command written explicitly, and is any material drift note recorded?
- Does the reviewer choose one explicit outcome: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`?
### High-impact validation path
Use an existing governed spec such as `specs/211-runtime-trend-recalibration/` or a similar multi-lane runtime-governance feature to prove that:
- the spec prompt surfaces lane and heavy-surface choices clearly
- the plan prompt exposes helper, fixture, or lane-shape impact
- the review checklist can ask whether the change creates a new cost center
- the escalation rules distinguish between local documentation and a true follow-up spec
Expected review outcome for `specs/211-runtime-trend-recalibration/`: `document-in-feature`, because the spec already records its own validation lanes, bounded fixture risk, unchanged heavy/browser scope, and runtime drift follow-up inside the active feature.
Expected result: the workflow surfaces the intended escalation decisions without adding a new approval bureaucracy and keeps the representative higher-cost review under 3 minutes.
## 6. Record the validation note
Capture the dry-run outcome in the active spec or implementation PR with at least:
- low-impact scenario used
- high-impact scenario used
- whether any prompt wording was confusing
- which explicit review outcome was chosen
- whether any review question felt redundant or missing
- whether the process stayed lightweight enough for ordinary work
## 7. Recorded dry-run results (2026-04-18)
- **Low-impact scenario**: `.specify/templates/checklist-template.md` plus `.specify/README.md`
Result: the spec and plan prompts were answerable with `N/A` or `none` in under 1 minute, and the checklist still closed with a clear `keep` outcome.
- **Higher-cost scenario**: `specs/211-runtime-trend-recalibration/spec.md` plus `specs/211-runtime-trend-recalibration/plan.md`
Result: the reviewer could reach `document-in-feature` in under 3 minutes because Spec 211 already documents lane fit, bounded helper cost, unchanged heavy/browser scope, and runtime-drift follow-up inside the active delivery artifact.
- **Document-in-feature example**: a feature widens evidence or trend reporting inside an existing governed lane family and records the runtime or recalibration note in its own spec or PR.
- **Follow-up-spec example**: a change introduces a new heavy family, normalizes browser coverage for a new workflow class, or revives an expensive shared default across multiple unrelated tests.
## 8. Completion checklist
- Constitution wording updated and aligned with `TEST-GOV-001`
- Spec, plan, task, and review surfaces use the same lane and escalation vocabulary
- Contributor guidance explains both low-impact and escalation-worthy cases
- Dry runs confirm the workflow is both usable and sufficiently strict
- The review checklist ends with one explicit outcome
- No new governance subsystem, bot, or duplicate handbook was introduced

View File

@ -0,0 +1,49 @@
# Research: Test Suite Authoring Constitution & Review Guardrails
## Decision 1: Reuse and sharpen existing `TEST-GOV-001` workflow surfaces
- **Decision**: Build Spec 212 by extending the current constitution and SpecKit template surfaces that already carry lane/runtime governance instead of inventing a new governance subsystem.
- **Rationale**: The repository already contains `TEST-GOV-001`, a `Testing / Lane / Runtime Impact` section in the spec template, a `Test Governance Check` in the plan template, and runtime-governance language in the task template and repository guidance. The missing value is stronger authoring-time classification, review guardrails, and escalation prompts, not new infrastructure.
- **Alternatives considered**:
- Create a dedicated test-governance framework with its own configuration and commands. Rejected because it would add a second process surface and drift risk.
- Rely on CI and reviewer discretion alone. Rejected because the spec explicitly targets prevention at authoring and review time.
## Decision 2: Keep contributor guidance inside existing repository entry points
- **Decision**: Prefer `.specify/README.md`, `README.md`, the updated templates, and this feature's `quickstart.md` over introducing a new standalone contributor handbook.
- **Rationale**: These are the surfaces contributors already read during spec work and repository setup. Reusing them keeps the guidance discoverable without creating another long-lived document that can drift.
- **Alternatives considered**:
- Add a new `docs/test-authoring-governance.md` file. Rejected because it would split the guidance away from the authoring workflow and increase maintenance burden.
- Encode all guidance only in the constitution. Rejected because contributors need operational examples at the point of use, not just high-level rules.
## Decision 3: Make review guardrails question-based, not score-based
- **Decision**: Model the review surface as a short set of direct questions plus explicit escalation outcomes rather than a weighted scorecard or approval rubric.
- **Rationale**: Reviewers need a fast keep, split, or escalate decision aid. Direct questions about lane fit, breadth, database and UI-heavy justification, fixture cost, and escalation need are easier to apply in under 3 minutes than a scoring framework.
- **Alternatives considered**:
- A weighted review rubric. Rejected because it would slow down reviews and encourage ritual over judgment.
- A long prose checklist. Rejected because it would be harder to scan and easier to ignore.
## Decision 4: Escalation stays document-first, not CI-block-first
- **Decision**: New heavy families, new browser scope, revived expensive defaults, and material lane-cost changes should trigger explicit documentation and follow-up decisions in the active spec or PR, not a new automatic CI policy.
- **Rationale**: These are judgment-heavy signals. The right first move is to make them visible and attributable at authoring and review time, not to bolt on a new blocking system that would be brittle and hard to calibrate.
- **Alternatives considered**:
- Fail CI immediately on any detected heavy-surface expansion. Rejected because many legitimate changes still need human context and scoping decisions.
- Treat escalation as optional reviewer prose. Rejected because optional language is exactly what the spec is trying to harden.
## Decision 5: Validate both the low-friction and high-risk paths
- **Decision**: Validate the updated workflow against one docs-only or template-only `N/A` flow and one higher-cost governed-spec flow that touches multiple runtime governance concerns.
- **Rationale**: The low-impact path proves the process stays lightweight. The higher-cost path proves the workflow can surface lane, heavy, fixture, and escalation questions before implementation.
- **Alternatives considered**:
- Validate only against a higher-cost spec. Rejected because it would not prove that ordinary low-impact work stays fast.
- Validate only against hypothetical examples. Rejected because real repo artifacts are needed to check phrasing and friction.
## Decision 6: Use logical contract artifacts for workflow semantics
- **Decision**: Represent the design with one schema-first governance-pack contract and one logical OpenAPI contract even though the feature adds no transport API, and treat both artifacts as design-time scaffolding rather than new maintained workflow surfaces.
- **Rationale**: Neighboring governance specs already use logical OpenAPI plus JSON Schema to describe repository-owned workflow truth. Reusing that pattern keeps planning artifacts consistent and gives the later task-generation step structured inputs.
- **Alternatives considered**:
- Markdown-only planning notes. Rejected because they are less structured and less reusable for task generation and validation.
- A runtime API contract. Rejected because this feature does not introduce a runtime service or endpoint.

View File

@ -0,0 +1,243 @@
# Feature Specification: Test Suite Authoring Constitution & Review Guardrails
**Feature Branch**: `212-test-authoring-guardrails`
**Created**: 2026-04-18
**Status**: Draft
**Input**: User description: "Spec 212 — Test Suite Authoring Constitution & Review Guardrails"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot can now measure, segment, and enforce test-suite cost, but contributors still lack a mandatory authoring and review routine that keeps new tests correctly classified, minimally provisioned, and lane-aware before they become permanent suite cost.
- **Today's failure**: New tests can still be written convenience-first, broaden shared helpers or fixtures, or expand heavy families without early disclosure, so the first strong signal often appears only after review fatigue or CI slowdown.
- **User-visible improvement**: Contributors and reviewers get lightweight, repeatable prompts that make lane impact, heavy risk, fixture cost, and escalation needs explicit while the change is still small and easy to redirect.
- **Smallest enterprise-capable version**: Extend the existing governance system with a short authoring constitution, mandatory test-impact prompts in spec and plan flows, a compact task checklist, a concise review checklist, explicit escalation rules, and contributor guidance validated against representative specs.
- **Explicit non-goals**: No new CI lanes, no new runtime-optimization program, no automatic PR bot, no broader coding constitution outside test authoring and review, and no attempt to replace human test design judgment with bureaucracy.
- **Permanent complexity imported**: A small set of governance prompts, reviewer questions, escalation vocabulary, contributor guidance, and maintenance responsibility for keeping those artifacts aligned with Specs 206 through 211.
- **Why now**: Specs 206 through 211 built the lane, budget, heavy-segmentation, CI, and trend foundation; without authoring-time and review-time guardrails, the suite can still drift back toward hidden cost growth at the exact point new tests are introduced.
- **Why not local**: Ad hoc reviewer discipline and tribal memory do not scale across contributors, feature specs, or future maintainers, and they do not leave a durable, reviewable record of why a costly test choice was accepted.
- **Approval class**: Cleanup
- **Red flags triggered**: New governance prompts across several authoring surfaces and some new shared vocabulary around escalation. Defense: the feature stays repository-scoped, avoids new runtime infrastructure, and intentionally closes an already active governance program instead of opening a new one.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: No end-user HTTP routes change. The affected surfaces are repository-owned governance artifacts: the test authoring constitution, specification routine, planning routine, task routine, review checklist, and contributor guidance.
- **Data Ownership**: Workspace-owned authoring templates, governance rules, review prompts, and validation notes. No tenant-owned records or product runtime tables are introduced.
- **RBAC**: No product authorization behavior changes. The actors are contributors, reviewers, and maintainers applying repository governance.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: yes, but only repository-owned governance artifacts such as constitution text, prompt blocks, checklists, and validation notes
- **New abstraction?**: no new software abstraction; only a documented decision routine for authoring and review
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Contributors can still introduce expensive or misclassified tests at authoring time, while reviewers lack a short, explicit checklist for catching avoidable suite-cost drift before merge.
- **Existing structure is insufficient because**: Lane budgets, CI enforcement, and trend reporting detect problems after a test already exists, but they do not reliably force contributors to justify heavy surfaces or shared setup cost while the change is still being designed.
- **Narrowest correct implementation**: Add lightweight governance text and prompt surfaces directly to the existing spec, plan, task, and review workflow instead of inventing new runtime tooling or a separate approval system.
- **Ownership cost**: Maintainers must keep the constitution text, prompt blocks, checklist language, and representative examples aligned as lane vocabulary and governance expectations evolve.
- **Alternative intentionally rejected**: Relying on informal reviewer comments, CI failures alone, or scattered contributor notes with no shared authoring contract.
- **Release truth**: Current-release repository truth needed to make the test-governance foundation from Specs 206 through 211 durable.
## Problem Statement
Specs 206 through 211 gave TenantPilot a strong technical foundation for test-suite governance:
- lane structure and runtime budgets exist
- shared fixture cost has been reduced
- heavy Filament and Livewire families have been segmented
- heavy-governance cost is treated explicitly
- CI runs the governed lanes and enforces their runtime expectations
- runtime trend and baseline logic make erosion visible over time
That foundation is strong, but it is still mostly reactive. The main remaining gap is the moment where new tests are conceived, written, and reviewed.
The biggest slowdown risks are still created at authoring time, when a contributor chooses whether a test stays narrow or immediately reaches for database, Livewire, Filament, browser, or broad shared helpers. Review is the last reliable checkpoint before those choices become permanent suite cost. If authoring and review lack explicit guardrails, the repository drifts back toward convenience-first testing and only learns about the damage after CI or runtime budgets start complaining.
This feature closes that gap by embedding the existing governance model directly into the daily workflow for specification, planning, tasking, authoring, and review.
## Dependencies
- Depends on Spec 206 — Test Suite Governance & Performance Foundation for lane vocabulary, cost awareness, and the original governance contract.
- Depends on Spec 207 — Shared Test Fixture Slimming for the expectation that common setup remains intentionally small.
- Depends on Spec 208 — Filament/Livewire Heavy Suite Segmentation for the definition and containment of expensive UI-driven families.
- Depends on Spec 209 — Heavy Governance Lane Cost Reduction for the principle that heavy governance must be deliberate rather than accidental.
- Depends on Spec 210 — CI Test Matrix & Runtime Budget Enforcement for enforced lane boundaries and runtime budget evidence.
- Depends on Spec 211 — Test Runtime Trend Reporting & Baseline Recalibration for the ability to see long-horizon cost drift and justify escalation.
- Recommended after the governed lanes, CI enforcement, and trend visibility are stable enough that the remaining problem is authoring and review behavior rather than missing infrastructure.
- Blocks durable, everyday embedding of the existing governance model at the point where new tests enter the suite.
- Does not block normal feature delivery when current reviewer discipline is already handling the risk manually.
## Goals
- Embed test-governance thinking directly into the normal development routine.
- Give contributors explicit rules for classifying and justifying new tests.
- Give reviewers concrete prompts that catch hidden suite-cost drift before merge.
- Require new specs, plans, and task lists to state their test and lane impact deliberately.
- Keep heavy-family creation, browser expansion, and shared setup cost from appearing silently.
- Prevent drift earlier than CI or budget failures.
- Close the open loop in the existing test-governance program.
## Non-Goals
- Creating another runtime-optimization or lane-segmentation spec.
- Expanding the CI matrix or adding new infrastructure by default.
- Replacing thoughtful test design with a rigid checklist ritual.
- Creating a universal engineering constitution for every domain outside test authoring and review.
- Introducing PR bots or fully automated review comments as a requirement for this slice.
- Reopening lane or budget design that Specs 206 through 211 already settled.
## Assumptions
- Specs 206 through 211 remain the authoritative source for lane vocabulary, heavy-family expectations, budget stewardship, and runtime-trend interpretation.
- The existing specification, planning, and task routines are the correct places to force early test-impact thinking.
- Reviewers will continue to use judgment; the checklist is meant to sharpen decisions, not replace them.
- Most feature work should still be able to satisfy the added prompts with concise answers rather than long essays.
## Key Decisions
- **Prevention is better than post-facto enforcement**: The cheapest place to control suite cost is before the test is committed, not after CI exposes the damage.
- **Constitution rules must stay lightweight but binding**: The authoring contract must be short enough to use every day and strong enough to matter when a costly choice appears.
- **Every spec must consider test impact explicitly**: New feature work should say which lane, family, and runtime implications it touches instead of leaving that question implicit.
- **Reviewers need decision-grade prompts**: Review guardrails should ask direct questions about lane fit, breadth, fixture cost, heavy-family creation, and escalation need rather than vague reminders to care about performance.
- **Classification must happen at authoring time**: Contributors should decide up front whether a test belongs in a narrow lane, a heavier lane, or a new heavy family.
- **New heavy cost centers must announce themselves**: New browser scope, new heavy families, major lane-cost movement, and revived expensive defaults require explicit escalation instead of silent normalization.
## Required Outcomes
### Test Authoring Constitution
The repository must gain a short, durable constitution section that states the standing rules for test classification, lane awareness, justified use of database or UI-heavy surfaces, minimal fixtures by default, and refusal of hidden shared-cost growth.
### Specification Routine Extension
Every new feature specification must answer a small, standard test-impact block that covers affected lanes, new or expanded test families, heavy or browser relevance, expected budget or trend effect, and the validation expected at review time.
### Planning Routine Extension
The planning workflow must make test-impact decisions visible before implementation by asking what test types change, whether helpers or fixtures widen, whether lane reshaping is needed, and what final validation is required.
### Task Routine Extension
Task lists must carry a short test-governance checklist that keeps lane assignment, minimal setup, relevant validation, and budget or trend disclosure visible while work is broken down.
### Review Guardrails
Reviewers must have a fast checklist that asks whether a test is in the right lane, whether it is unnecessarily broad, whether database or UI-heavy surfaces are actually required, whether setup is secretly expensive, whether the change should be split, and whether escalation is required. The canonical daily-use review surface is the generated checklist based on `.specify/templates/checklist-template.md`, with `.specify/README.md` acting as the reviewer entry point for how to apply it.
### Escalation Rules
The governance model must define when a change stops being an ordinary test delta and becomes a governance signal that needs explicit documentation or a follow-up spec, especially for new heavy families, new browser coverage, material lane-cost changes, revived expensive defaults, or broad suite reshaping.
### Contributor Guidance
Contributors must get short guidance that explains how to choose between narrow and heavy test surfaces, how to detect an overly broad test, when shared setup is justified, and when a change belongs inside an existing family versus creating a new one.
### Workflow Integration
The resulting rules must appear where they are used: in the constitution, specification routine, planning routine, task routine, review checklist, and lightweight contributor-facing guidance.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Validation lane(s)**: N/A
- **Why these lanes are sufficient**: N/A. This feature changes repository authoring and review artifacts rather than product runtime behavior.
- **New or expanded test families**: none
- **Fixture / helper cost impact**: none directly. The intended effect is future prevention of unnecessary shared setup cost rather than immediate new fixture or helper behavior.
- **Heavy coverage justification**: none
- **Budget / baseline / trend impact**: none directly. The feature should improve earlier disclosure of future drift, but it does not itself change lane membership, budgets, baselines, or runtime measurements.
- **Planned validation commands**: N/A. Validation is document-based and consists of applying the new prompts and guardrails to representative specs, plans, and task flows.
## Workflow Validation Notes (2026-04-18)
### Low-Impact Authoring Dry Run
- **Scenario**: Apply the updated prompts to a template-only change limited to `.specify/templates/checklist-template.md` and `.specify/README.md`.
- **Result**: The authoring flow can be completed with concise `N/A` or `none` answers in under 1 minute because the prompts only ask for runtime-specific detail when impact actually exists.
- **Wording adjustment captured**: The spec and plan templates now ask for a short reviewer handoff and an explicit escalation outcome so low-impact work stays lightweight while still ending in a clear review disposition.
### Higher-Cost Review Dry Run
- **Scenario**: Apply the updated review guardrails to `specs/211-runtime-trend-recalibration/spec.md` and `specs/211-runtime-trend-recalibration/plan.md`.
- **Result**: The reviewer can confirm lane fit, bounded helper cost, no new heavy/browser promotion, and explicit validation commands in under 3 minutes. The correct outcome is `document-in-feature` because Spec 211 changes governed runtime-reporting behavior inside existing lane families and already records its own drift and recalibration notes.
- **Escalation boundary proved**: A true `follow-up-spec` remains reserved for recurring pain or structural lane-model changes, such as introducing a new heavy family, normalizing browser coverage for a new workflow class, or reviving an expensive shared default across unrelated tests.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Classify Test Impact While Authoring (Priority: P1)
As a contributor preparing a new feature spec or plan, I want the workflow to ask about lane impact, heavy coverage, and fixture cost before implementation begins so I choose the smallest justified test surface instead of defaulting to convenience-first coverage.
**Why this priority**: This is the earliest and cheapest place to stop avoidable suite-cost drift.
**Independent Test**: Apply the workflow to a genuinely low-impact docs-only or template-only scenario, such as a change limited to `.specify/templates/checklist-template.md` and `.specify/README.md`, and confirm that the author can answer with concise `N/A` or `none` responses while still making any affected lanes, new or expanded test families, heavy-surface justification, and minimal validation expectations explicit when they exist.
**Acceptance Scenarios**:
1. **Given** a feature spec that introduces or changes tests, **When** the author completes the required governance prompts, **Then** the spec states the affected lane or lanes, any family expansion, and the required validation scope explicitly.
2. **Given** a proposed test that reaches for database, Livewire, Filament, or browser coverage, **When** the author documents the approach, **Then** the justification and minimal-setup expectation are stated rather than assumed.
---
### User Story 2 - Reviewers Catch Hidden Suite Cost Before Merge (Priority: P1)
As a reviewer evaluating new or changed tests, I want a short guardrail checklist so I can quickly judge whether the test belongs in the chosen lane, whether the setup is too broad, and whether the change needs escalation instead of silent acceptance.
**Why this priority**: Review is the last reliable checkpoint before hidden cost becomes permanent repository truth.
**Independent Test**: Use the canonical generated review checklist on representative test changes and confirm that the reviewer can reach a clear keep, split, or escalate decision without relying on unwritten tribal knowledge.
**Acceptance Scenarios**:
1. **Given** a test that is broader than necessary for its intent, **When** the reviewer applies the checklist, **Then** the checklist makes the breadth and likely narrower alternative visible.
2. **Given** a change that quietly expands a heavy family or shared helper default, **When** the reviewer applies the checklist, **Then** the need for explicit escalation or follow-up governance is surfaced before merge.
---
### User Story 3 - Escalate New Cost Centers Deliberately (Priority: P2)
As a maintainer stewarding suite health, I want clear escalation rules so that new heavy families, new browser scope, or material lane-cost shifts are documented and evaluated explicitly instead of being normalized through drift.
**Why this priority**: The governance model stays durable only if major new cost centers announce themselves early and visibly.
**Independent Test**: Apply the escalation rules to representative examples involving new browser or heavy scope and confirm that the outcome is either a documented local exception or an explicit follow-up governance action.
**Acceptance Scenarios**:
1. **Given** a change that introduces a new heavy family or new browser coverage, **When** the escalation rules are applied, **Then** the change is classified as an explicit governance decision rather than a routine test edit.
2. **Given** a small test change that stays within an existing lane and family, **When** the escalation rules are applied, **Then** the workflow allows the change to proceed without forcing unnecessary process overhead.
### Edge Cases
- A feature has no meaningful runtime or test impact; the workflow must allow concise `N/A` or `none` answers instead of forcing boilerplate.
- One feature legitimately affects multiple existing lanes; the prompts must allow multi-lane disclosure without implying a new family.
- A seemingly small helper or factory default would silently broaden setup cost across many tests; the guardrails must treat this as a governance concern even if the local diff looks minor.
- A reviewer sees budget or baseline implications before CI is red; the escalation rules must allow early documentation rather than waiting for a hard failure.
- A single justified browser or heavy scenario must not automatically bless wider copy-paste expansion into nearby tests.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The repository MUST define a permanent test authoring constitution that requires explicit test classification, deliberate lane awareness, justified use of database or UI-heavy surfaces, minimal fixtures by default, and rejection of hidden shared-cost growth.
- **FR-002**: The specification routine MUST require a standard test-impact section for every new spec that captures affected lane or lanes, new or expanded test families, heavy or browser relevance, expected budget or trend implications, and reviewer validation expectations, or explicit `N/A` or `none` answers when no such impact exists.
- **FR-003**: The planning routine MUST require a test-impact block that identifies which test types change, whether shared helpers, fixtures, factories, or defaults widen, whether lane reassignment or lane addition is implicated, and what final validation is required.
- **FR-004**: The task routine MUST include a short standardized checklist that confirms lane assignment, avoidance of unnecessary heavy cost, use of minimal fixtures or helpers, relevant validation, and documentation of budget or trend implications when present.
- **FR-005**: The review routine MUST provide a concise guardrail checklist that asks whether the test is in the correct lane, whether it is unnecessarily broad, whether database, Livewire, Filament, or browser usage is justified, whether setup is secretly expensive, whether the test should be split, and whether escalation is required. The canonical checklist surface is the generated review checklist based on `.specify/templates/checklist-template.md`, with `.specify/README.md` linking reviewers to its use.
- **FR-006**: The governance model MUST define explicit escalation rules for new heavy families, new browser coverage, material lane-cost change, broad new Filament or Livewire governance surfaces, revived expensive helper or factory defaults, budget or baseline relevant shifts, and major suite reshaping.
- **FR-007**: Contributor guidance MUST explain how to choose between narrow and heavy test surfaces, when database or UI-heavy coverage is justified, how to recognize an overly broad test, and when to extend an existing family versus introduce a new one.
- **FR-008**: The guardrails MUST be integrated into the everyday authoring and review surfaces used by contributors and reviewers, including the constitution, specification routine, planning routine, task routine, review checklist, and contributor guidance.
- **FR-009**: The added governance prompts MUST remain lightweight enough that an ordinary feature with little or no test impact can satisfy them with concise answers and without material process drag.
- **FR-010**: The completed guidance MUST be validated against at least one representative low-risk docs-only or template-only flow, such as a change limited to `.specify/templates/checklist-template.md` and `.specify/README.md`, and one representative higher-cost or multi-lane scenario to confirm that the rules are usable, do not contradict existing lane or budget governance, and catch the intended escalation cases.
- **FR-011**: The governance rules MUST explicitly forbid introducing new expensive shared helper, factory, seed, or fixture defaults without disclosing the cost impact and either containing the change locally or escalating it as governance-relevant work.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In dry runs on at least two representative feature specs, authors can complete the required test-impact prompts with no unanswered required field and with the affected lane or lanes, family impact, and validation scope made explicit.
- **SC-002**: In representative review exercises, reviewers can use the guardrail checklist to reach a clear keep, split, or escalate decision within 3 minutes for each sample change.
- **SC-003**: Every validation example that introduces new browser coverage, a new heavy family, or a material lane-cost shift is explicitly classified as either a documented local exception or a governance escalation; none remain implicit.
- **SC-004**: A representative low-impact docs-only or template-only scenario with no runtime or meaningful test change can satisfy the added governance prompts in under 1 minute using concise `N/A` or `none` answers.
- **SC-005**: Validation against representative specs, plans, and task flows shows no contradiction with the existing lane, budget, baseline, or runtime-trend model established by Specs 206 through 211.

View File

@ -0,0 +1,169 @@
# Tasks: Test Suite Authoring Constitution & Review Guardrails
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/212-test-authoring-guardrails/`
**Prerequisites**: `plan.md` (required), `spec.md` (required), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Tests**: Not required. This feature is docs and workflow only, so validation is by representative low-impact and higher-cost dry runs, cross-artifact consistency review, and recording the outcomes in the active spec artifacts.
**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently where possible.
## Phase 1: Setup (Shared Context)
**Purpose**: Freeze the real repository workflow surfaces before editing them.
- [X] T001 Audit `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, and `README.md` against `specs/212-test-authoring-guardrails/spec.md` and `specs/212-test-authoring-guardrails/plan.md` to confirm the exact guardrail gaps this feature must close
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared vocabulary that every later template and checklist update depends on.
**Critical**: No user story work should begin until this phase is complete.
- [X] T002 Update `.specify/memory/constitution.md` with the canonical test authoring and review guardrail rules for classification, lane awareness, heavy-surface justification, minimal fixtures, expensive-default bans, reviewer expectations, and escalation outcomes
**Checkpoint**: The shared governance vocabulary is stable enough for story-specific template and guidance updates.
---
## Phase 3: User Story 1 - Classify Test Impact While Authoring (Priority: P1) 🎯 MVP
**Goal**: Make contributors declare lane impact, heavy justification, and minimal proof while writing specs and plans.
**Independent Test**: Apply the updated spec and plan prompts to a genuinely low-impact template-only scenario limited to `.specify/templates/checklist-template.md` and `.specify/README.md`, confirm the low-impact path can be answered with concise `N/A` or `none` responses, and verify the required authoring questions are explicit.
### Implementation for User Story 1
- [X] T003 [P] [US1] Update `.specify/templates/spec-template.md` so `Testing / Lane / Runtime Impact` explicitly asks for affected lane fit, heavy-surface justification, fixture or helper cost disclosure, escalation triggers, and concise `N/A` or `none` handling
- [X] T004 [P] [US1] Update `.specify/templates/plan-template.md` so `Test Governance Check` explicitly asks for changed test types, helper or factory widening, lane reshaping, closing validation, and where material drift notes must be recorded
- [X] T005 [US1] Validate the authoring flow using a low-impact template-only scenario limited to `.specify/templates/checklist-template.md` and `.specify/README.md` as the representative `N/A` path, then record the outcome and any wording adjustments in `specs/212-test-authoring-guardrails/spec.md` and `specs/212-test-authoring-guardrails/quickstart.md`
**Checkpoint**: Contributors can classify test impact during spec and plan authoring without extra workflow overhead.
---
## Phase 4: User Story 2 - Reviewers Catch Hidden Suite Cost Before Merge (Priority: P1)
**Goal**: Give reviewers a fixed, quick checklist that surfaces hidden test cost and points to clear outcomes.
**Independent Test**: Apply the updated review checklist to a representative higher-cost governed spec flow and confirm the reviewer can reach a keep, split, or escalate decision in under 3 minutes.
### Implementation for User Story 2
- [X] T006 [P] [US2] Update `.specify/templates/tasks-template.md` so generated task lists carry a short test-governance checklist for lane assignment, minimal setup, relevant validation, hidden-cost prevention, and budget or trend note visibility
- [X] T007 [P] [US2] Update `.specify/templates/checklist-template.md` as the canonical generated review-checklist surface with a fixed guardrail structure covering lane fit, breadth, DB or UI-heavy necessity, setup cost, split need, and escalation outcomes
- [X] T008 [US2] Update `.specify/README.md` as the reviewer entry point for applying the canonical review checklist and interpreting `keep`, `split`, `document-in-feature`, `follow-up-spec`, and `reject-or-split` outcomes
- [X] T009 [US2] Validate the review guardrails against `specs/211-runtime-trend-recalibration/spec.md` and `specs/211-runtime-trend-recalibration/plan.md`, then record the representative review outcome and timing note in `specs/212-test-authoring-guardrails/spec.md` and `specs/212-test-authoring-guardrails/quickstart.md`
**Checkpoint**: Reviewers have a stable guardrail surface that catches hidden suite cost before merge.
---
## Phase 5: User Story 3 - Escalate New Cost Centers Deliberately (Priority: P2)
**Goal**: Make new heavy families, new browser scope, revived expensive defaults, and material lane-cost shifts announce themselves explicitly.
**Independent Test**: Apply the escalation rules to a representative higher-cost multi-lane workflow and confirm the result distinguishes between local documentation and a true follow-up governance action.
### Implementation for User Story 3
- [X] T010 [P] [US3] Update `README.md` with concise contributor guidance for choosing unit vs feature vs heavy-governance vs browser coverage, justifying database or UI-heavy usage, recognizing over-broad tests, spotting escalation triggers early, and deciding when to extend an existing family versus introduce a new one
- [X] T011 [US3] Update `specs/212-test-authoring-guardrails/quickstart.md` with the canonical low-impact validation scenario, the canonical review-checklist surface, and explicit document-in-feature vs follow-up-spec escalation examples that match the live templates and docs
- [X] T012 [US3] Validate escalation handling against a representative higher-cost multi-lane flow using `specs/211-runtime-trend-recalibration/spec.md`, `specs/211-runtime-trend-recalibration/plan.md`, and the implemented guidance surfaces, then record document-local vs follow-up-spec examples in `specs/212-test-authoring-guardrails/spec.md` and `specs/212-test-authoring-guardrails/quickstart.md`
**Checkpoint**: Escalation-worthy test cost changes are explicit, documented, and consistently interpreted.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Reconcile the finished workflow surfaces and remove drift between the templates, guidance, and active spec artifacts.
- [X] T013 Run the `specs/212-test-authoring-guardrails/quickstart.md` completion checklist against `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `README.md`, and `specs/212-test-authoring-guardrails/spec.md`, then remove any duplicated or conflicting wording across the updated governance surfaces
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies and can start immediately.
- **Foundational (Phase 2)**: Depends on Phase 1 and blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on Phase 2 and is the MVP slice.
- **User Story 2 (Phase 4)**: Depends on Phase 2 and can proceed independently of User Story 1 once the shared vocabulary is stable.
- **User Story 3 (Phase 5)**: Depends on Phase 2 and benefits from the implemented authoring and review surfaces from User Stories 1 and 2 before final escalation examples are recorded.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: Can begin immediately after Foundational and delivers the first usable authoring workflow increment.
- **User Story 2 (P1)**: Can begin immediately after Foundational and delivers a separate review workflow increment.
- **User Story 3 (P2)**: Reuses the stable vocabulary from Foundational and should finalize once the live authoring and review surfaces are in place.
### Within Each User Story
- Shared vocabulary changes in `.specify/memory/constitution.md` must land before any template or checklist wording is finalized.
- Template changes should be implemented before story-specific validation notes are recorded in `spec.md` and `quickstart.md`.
- Low-impact and higher-cost dry-run validation must complete before closing the corresponding story.
- Cross-artifact cleanup should happen only after all targeted workflow surfaces are updated.
### Parallel Opportunities
- T003 and T004 can run in parallel because they update different template surfaces for the same authoring flow.
- T006 and T007 can run in parallel because they update different checklist-producing template surfaces.
- T010 can run in parallel with the earlier story validation recording once the shared vocabulary is stable because it targets root contributor guidance rather than the SpecKit templates.
---
## Parallel Example: User Story 1
```bash
# After T002 establishes the shared vocabulary, these can proceed in parallel:
Task: "Update .specify/templates/spec-template.md"
Task: "Update .specify/templates/plan-template.md"
```
---
## Parallel Example: User Story 2
```bash
# After T002 establishes the shared vocabulary, these can proceed in parallel:
Task: "Update .specify/templates/tasks-template.md"
Task: "Update .specify/templates/checklist-template.md"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1.
4. Validate the low-impact `N/A` path using a template-only scenario limited to `.specify/templates/checklist-template.md` and `.specify/README.md` before continuing.
### Incremental Delivery
1. Lock the shared constitution vocabulary first.
2. Deliver the authoring prompts for specs and plans.
3. Deliver the reviewer-facing task and checklist surfaces.
4. Add contributor guidance and explicit escalation examples.
5. Finish with cross-artifact cleanup and quickstart completion review.
### Parallel Team Strategy
1. One contributor can update the spec and plan templates while another prepares the task and checklist template changes after Foundational is done.
2. Reviewer guidance in `.specify/README.md` can follow once the checklist surface is stable.
3. Root `README.md` contributor guidance and final escalation examples can be completed in parallel with late-stage validation-note drafting.
---
## Notes
- `[P]` tasks operate on different files or independent workflow surfaces and can run in parallel once dependencies are satisfied.
- `[US1]`, `[US2]`, and `[US3]` map tasks directly to the user stories in `spec.md`.
- This feature is docs and workflow only, so validation is recorded in the active spec artifacts rather than by running Pest lanes.
- The final workflow must stay lightweight for low-impact work while still surfacing explicit escalation for new test cost centers.