Compare commits

..

4 Commits

Author SHA1 Message Date
58f9bb7355 chore: commit all workspace changes (#275)
Some checks failed
Main Confidence / confidence (push) Failing after 1m34s
Auto-generated PR: commit all workspace changes (includes .github/skills addition).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #275
2026-04-25 09:13:54 +00:00
110245a9ec feat: neutralize provider connection target-scope surfaces (#274)
Some checks are pending
Main Confidence / confidence (push) Waiting to run
## Summary
- add a shared provider target-scope descriptor, normalizer, identity-context metadata, and surface-summary layer
- update provider connection list, detail, create, edit, and onboarding surfaces to use neutral target-scope vocabulary while keeping Microsoft identity contextual
- align provider connection audit and resolver output with the neutral target-scope contract and add focused guard/unit/feature coverage for regressions

## Validation
- browser smoke: opened the tenant-scoped provider connection list, drilled into detail, and verified the edit/create surfaces in local admin context

## Notes
- this PR comes from the session branch created for the active feature work
- no additional runtime or persistence layer was introduced in this slice

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #274
2026-04-25 09:07:40 +00:00
bd26e209de feat: harden provider boundaries (#273)
Some checks failed
Main Confidence / confidence (push) Failing after 57s
## Summary
- add the provider boundary catalog, boundary support types, and guardrails for platform-core versus provider-owned seams
- harden provider gateway, identity resolution, operation registry, and start-gate behavior to require explicit provider bindings
- add unit and feature coverage for boundary classification, runtime preservation, unsupported paths, and platform-core leakage guards
- add the full Spec Kit artifact set for spec 237 and update roadmap/spec-candidate tracking

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderGatewayTest.php tests/Unit/Providers/ProviderIdentityResolverTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- browser smoke: `http://localhost/admin/provider-connections?tenant_id=18000000-0000-4000-8000-000000000180` loaded with the local smoke user, the empty-state CTA reached the canonical create route, and cancel returned to the scoped list

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #273
2026-04-24 21:05:37 +00:00
6a5b8a3a11 feat: canonical control catalog foundation (#272)
Some checks failed
Main Confidence / confidence (push) Failing after 50s
## Summary
- add a config-seeded canonical control catalog plus shared resolution primitives and Microsoft subject bindings
- propagate canonical control references into findings-derived evidence snapshots and tenant review composition
- add the feature spec artifacts and focused Pest coverage, plus the supporting workspace and Sail helper adjustments included in this branch

## Testing
- cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php tests/Feature/PlatformRelocation/CommandModelSmokeTest.php
- cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #272
2026-04-24 12:26:02 +00:00
74 changed files with 6302 additions and 197 deletions

View File

@ -250,6 +250,10 @@ ## Active Technologies
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure (236-canonical-control-catalog-foundation)
- PostgreSQL for existing downstream governance artifacts plus a product-seeded in-repo canonical control registry; no new DB-backed control authoring table in the first slice (236-canonical-control-catalog-foundation)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4 (237-provider-boundary-hardening)
- Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables (237-provider-boundary-hardening)
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4 (238-provider-identity-target-scope)
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -284,9 +288,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
- 236-canonical-control-catalog-foundation: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing governance domain models and builders, existing Evidence Snapshot and Tenant Review infrastructure
- 235-baseline-capture-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces
- 234-dead-transitional-residue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `App\Models\BaselineProfile`, `App\Support\Baselines\BaselineProfileStatus`, `App\Support\Badges\BadgeCatalog`, `App\Support\Badges\BadgeDomain`, `Database\Factories\TenantFactory`, `App\Console\Commands\SeedBackupHealthBrowserFixture`, existing tenant-truth and baseline-profile Pest tests
<!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check

View File

@ -0,0 +1,383 @@
---
name: spec-kit-next-best-one-shot
description: Select the most suitable next TenantPilot/TenantAtlas spec from roadmap and spec-candidates, then create Spec Kit preparation artifacts in one pass: spec.md, plan.md, and tasks.md. Use when the user wants the agent to choose the next best spec based on roadmap fit, current candidates, repository state, platform priorities, governance foundations, UX improvements, architecture cleanup, or implementation readiness. This skill must not implement application code.
---
# Skill: Spec Kit Next-Best One-Shot Preparation
## Purpose
Use this skill when the user wants the agent to select the most suitable next spec from existing product planning sources and then create the Spec Kit preparation artifacts in one pass:
1. choose the next best spec candidate
2. create `spec.md`
3. create `plan.md`
4. create `tasks.md`
5. provide a manual analysis prompt
This skill prepares implementation work, but it must not perform implementation.
The intended workflow is:
```text
roadmap.md + spec-candidates.md
→ select next best spec
→ one-shot spec + plan + tasks preparation
→ manual repo-based analysis/review
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks things like:
```text
Nimm die nächste sinnvollste Spec aus roadmap und spec-candidates.
```
```text
Wähle die nächste geeignete Spec und erstelle spec, plan und tasks.
```
```text
Schau in roadmap.md und spec-candidates.md und mach daraus die nächste Spec.
```
```text
Such die beste nächste Spec aus und bereite sie in einem Rutsch vor.
```
```text
Nimm angesichts Roadmap und Spec Candidates das sinnvollste nächste Thema.
```
## Hard Rules
- Work strictly repo-based.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
- Do not execute implementation commands.
- Do not run destructive commands.
- Do not invent roadmap priorities not supported by repository documents.
- Do not pick a spec only because it is listed first.
- Do not select broad platform rewrites when a smaller dependency-unlocking spec is more appropriate.
- Prefer specs that unlock roadmap progress, reduce architectural drift, harden foundations, or remove known blockers.
- Prefer small, reviewable, implementation-ready specs over large ambiguous themes.
- Preserve TenantPilot/TenantAtlas terminology.
- Follow the repository constitution and existing Spec Kit conventions.
- If repository truth conflicts with roadmap/candidate wording, keep repository truth and document the deviation.
- If no candidate is suitable, create no spec and explain why.
## Required Repository Checks
Before selecting the next spec, inspect:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `specs/`
4. `docs/product/spec-candidates.md`
5. roadmap documents under `docs/product/`, especially `roadmap.md` if present
6. nearby existing specs related to top candidate areas
7. current spec numbering conventions
8. application code only as needed to verify whether a candidate is already done, blocked, duplicated, or technically mis-scoped
Do not edit application code.
## Candidate Selection Criteria
Evaluate candidate specs using these criteria.
### 1. Roadmap Fit
Prefer candidates that directly support the current roadmap sequence or unlock the next roadmap layer.
Examples:
- governance foundations before advanced compliance views
- evidence/snapshot foundations before auditor packs
- control catalog foundations before CIS/NIS2 mappings
- decision/workflow surfaces before autonomous governance
- provider/platform boundary cleanup before multi-provider expansion
### 2. Foundation Value
Prefer candidates that strengthen reusable platform foundations:
- RBAC and workspace/tenant isolation
- auditability
- evidence and snapshot truth
- operation observability
- provider boundary neutrality
- canonical vocabulary
- baseline/control/finding semantics
- enterprise detail-page or decision-surface patterns
### 3. Dependency Unblocking
Prefer specs that unblock multiple later candidates.
A good next spec should usually make future specs smaller, safer, or more consistent.
### 4. Scope Size
Prefer a candidate that can be implemented as a narrow, testable slice.
Avoid selecting:
- broad platform rewrites
- vague product themes
- multi-feature bundles
- speculative future-provider frameworks
- large UX redesigns without a clear first slice
### 5. Repo Readiness
Prefer candidates where the repository already has enough structure to implement the next slice safely.
Check whether related models, services, UI pages, tests, or concepts already exist.
### 6. Risk Reduction
Prefer candidates that reduce current architectural or product risk:
- legacy dual-world semantics
- unclear truth ownership
- inconsistent operator UX
- missing audit/evidence boundaries
- repeated manual workflow friction
- false-positive calmness in governance surfaces
### 7. User/Product Value
Prefer specs that produce visible operator value or make the platform more sellable without creating heavy scope.
## Candidate Selection Output
Before creating files, prepare a concise decision summary for the final response.
The selected candidate should include:
- selected candidate title
- why it was selected
- why the nearest alternatives were not selected now
- roadmap relationship
- expected implementation slice
Do not create multiple specs unless the repository convention explicitly supports it and the user asked for it.
## Selection Matrix
When comparing candidates, use a small matrix internally or in the final summary:
| Candidate | Roadmap fit | Foundation value | Scope size | Repo readiness | Risk reduction | Decision |
|---|---:|---:|---:|---:|---:|---|
Keep it concise. Do not over-analyze if the best candidate is obvious.
## Spec Directory Rules
Create a new spec directory using the next valid spec number and a kebab-case slug:
```text
specs/<number>-<slug>/
```
The exact number must be derived from the current repository state and existing numbering conventions.
Create or update only these preparation artifacts inside the selected spec directory:
```text
specs/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/tasks.md
```
If the repository templates require additional preparation files, create them only when consistent with existing Spec Kit conventions.
Do not create implementation files.
## `spec.md` Requirements
The spec must be product- and behavior-oriented.
Include:
- Feature title
- Selected-candidate rationale
- Problem statement
- Business/product value
- Roadmap relationship
- Primary users/operators
- User stories
- Functional requirements
- Non-functional requirements
- UX requirements
- RBAC/security requirements
- Auditability/observability requirements
- Data/truth-source requirements where relevant
- Out of scope
- Acceptance criteria
- Success criteria
- Risks
- Assumptions
- Open questions
- Follow-up spec candidates if the selected candidate had to be narrowed
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
- workspace/tenant isolation
- capability-first RBAC
- auditability
- operation/result truth separation
- source-of-truth clarity
- calm enterprise operator UX
- progressive disclosure where useful
- no false positive calmness
- provider/platform boundary clarity where relevant
- versioned governance semantics where relevant
## `plan.md` Requirements
The plan must be repo-aware and implementation-oriented, but must not implement.
Include:
- Technical approach
- Existing repository surfaces likely affected
- Domain/model implications
- UI/Filament implications
- Livewire implications where relevant
- OperationRun/monitoring implications where relevant
- RBAC/policy implications
- Audit/logging/evidence implications where relevant
- Data/migration implications where relevant
- Test strategy
- Rollout considerations
- Risk controls
- Implementation phases
Where relevant, clearly distinguish:
- execution truth
- artifact truth
- backup/snapshot truth
- evidence truth
- recovery confidence
- operator next action
Use those distinctions only when relevant to the selected spec.
## `tasks.md` Requirements
Tasks must be ordered, small, and verifiable.
Include:
- checkbox tasks
- phase grouping
- tests before or alongside implementation tasks where practical
- final validation tasks
- documentation/update tasks if needed
- explicit non-goals where useful
Avoid vague tasks such as:
```text
Clean up code
Refactor UI
Improve performance
Make it enterprise-ready
```
Prefer concrete tasks such as:
```text
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
## Scope Control
If the selected roadmap/candidate item is too broad, narrow it into the smallest valuable first implementation slice.
Add a `Follow-up spec candidates` section for deferred concerns.
Examples of follow-up candidates:
- assigned findings
- pending approvals
- personal work queue
- notification delivery settings
- evidence pack export hardening
- operation monitoring refinements
- autonomous governance decision surfaces
- compliance mapping library expansion
- MSP portfolio rollups
- provider-specific adapters
Do not force follow-up candidates into the primary spec.
## Final Response Requirements
After creating or updating the artifacts, respond with:
1. Selected candidate
2. Why this candidate was selected
3. Why close alternatives were deferred
4. Created or updated spec directory
5. Files created or updated
6. Important repo-based adjustments made
7. Assumptions made
8. Open questions, if any
9. Recommended next manual analysis prompt
10. Explicit statement that no implementation was performed
Keep the response concise, but include enough detail for the user to continue immediately.
## Required Next Manual Analysis Prompt
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
Ziel:
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar, roadmap-konform und constitution-konform sind.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe nur gegen Repo-Wahrheit.
- Prüfe auch, ob die ausgewählte Spec wirklich die sinnvollste nächste Spec aus `docs/product/spec-candidates.md` und `docs/product/roadmap.md` war.
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
```
## Example Invocation
User:
```text
Nutze den Skill spec-kit-next-best-one-shot.
Wähle aus roadmap.md und spec-candidates.md die nächste sinnvollste Spec und erstelle spec, plan und tasks in einem Rutsch.
Keine Implementierung.
```
Expected behavior:
1. Inspect constitution, templates, specs, roadmap, and spec candidates.
2. Compare candidate suitability.
3. Select the next best candidate.
4. Determine the next valid spec number.
5. Create `spec.md`, `plan.md`, and `tasks.md`.
6. Keep scope tight.
7. Do not implement.
8. Return selection rationale, artifact summary, and next manual analysis prompt.

View File

@ -0,0 +1,294 @@
---
name: spec-kit-one-shot-prep
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
---
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
Define the functionality provided by this skill, including detailed instructions and examples
---
name: spec-kit-one-shot-prep
description: Create Spec Kit preparation artifacts in one pass for TenantPilot/TenantAtlas features: spec.md, plan.md, and tasks.md. Use for feature ideas, roadmap items, spec candidates, governance/platform improvements, UX improvements, cleanup candidates, and repo-based preparation before manual analysis or implementation. This skill must not implement application code.
---
# Skill: Spec Kit One-Shot Preparation
## Purpose
Use this skill to create a complete Spec Kit preparation package for a new TenantPilot/TenantAtlas feature in one pass:
1. `spec.md`
2. `plan.md`
3. `tasks.md`
This skill prepares implementation work, but it must not perform implementation.
The intended workflow is:
```text
feature idea / roadmap item / spec candidate
→ one-shot spec + plan + tasks preparation
→ manual repo-based analysis/review
→ explicit implementation step later
```
## When to Use
Use this skill when the user asks to create or prepare Spec Kit artifacts from:
- a feature idea
- a spec candidate
- a roadmap item
- a product or UX requirement
- a governance/platform improvement
- an architecture cleanup candidate
- a refactoring preparation request
- a TenantPilot/TenantAtlas implementation idea that should first become a formal spec
Typical user prompts:
```text
Mach daraus spec, plan und tasks in einem Rutsch.
```
```text
Erstelle daraus eine neue Spec Kit Vorbereitung, aber noch nicht implementieren.
```
```text
Nimm diesen spec candidate und bereite spec/plan/tasks vor.
```
```text
Erzeuge die Spec Kit Artefakte, danach mache ich die Analyse manuell.
```
## Hard Rules
- Work strictly repo-based.
- Do not implement application code.
- Do not modify production code.
- Do not modify migrations, models, services, jobs, Filament resources, Livewire components, policies, commands, or tests unless the user explicitly starts a later implementation task.
- Do not execute implementation commands.
- Do not run destructive commands.
- Do not expand scope beyond the provided feature idea.
- Do not invent architecture that conflicts with repository truth.
- Do not create broad platform rewrites when a smaller implementable spec is possible.
- Prefer small, reviewable, implementation-ready specs.
- Preserve TenantPilot/TenantAtlas terminology.
- Follow the repository constitution and existing Spec Kit conventions.
- If repository truth conflicts with the user-provided draft, keep repository truth and document the deviation.
- If the feature is too broad, split it into one primary spec and optional follow-up spec candidates.
## Required Inputs
The user should provide at least one of:
- feature title and short goal
- full spec candidate
- roadmap item
- rough problem statement
- UX or architecture improvement idea
If the input is incomplete, proceed with the smallest reasonable interpretation and document assumptions. Do not block on clarification unless the request is impossible to scope safely.
## Required Repository Checks
Before creating or updating Spec Kit artifacts, inspect the relevant repository sources.
Always check:
1. `.specify/memory/constitution.md`
2. `.specify/templates/`
3. `specs/`
4. `docs/product/spec-candidates.md`
5. relevant roadmap documents under `docs/product/`
6. nearby existing specs with related terminology or scope
Check application code only as needed to avoid wrong naming, wrong architecture, or duplicate concepts. Do not edit application code.
## Spec Directory Rules
Create a new spec directory using the next valid spec number and a kebab-case slug:
```text
specs/<number>-<slug>/
```
The exact number must be derived from the current repository state and existing numbering conventions.
Create or update only these preparation artifacts inside the selected spec directory:
```text
specs/<number>-<slug>/spec.md
specs/<number>-<slug>/plan.md
specs/<number>-<slug>/tasks.md
```
If the repository templates require additional preparation files, create them only when this is consistent with existing Spec Kit conventions. Do not create implementation files.
## `spec.md` Requirements
The spec must be product- and behavior-oriented. It should avoid premature implementation detail unless needed for correctness.
Include:
- Feature title
- Problem statement
- Business/product value
- Primary users/operators
- User stories
- Functional requirements
- Non-functional requirements
- UX requirements
- RBAC/security requirements
- Auditability/observability requirements
- Data/truth-source requirements where relevant
- Out of scope
- Acceptance criteria
- Success criteria
- Risks
- Assumptions
- Open questions
TenantPilot/TenantAtlas specs should preserve enterprise SaaS principles:
- workspace/tenant isolation
- capability-first RBAC
- auditability
- operation/result truth separation
- source-of-truth clarity
- calm enterprise operator UX
- progressive disclosure where useful
- no false positive calmness
## `plan.md` Requirements
The plan must be repo-aware and implementation-oriented, but still must not implement.
Include:
- Technical approach
- Existing repository surfaces likely affected
- Domain/model implications
- UI/Filament implications
- Livewire implications where relevant
- OperationRun/monitoring implications where relevant
- RBAC/policy implications
- Audit/logging/evidence implications where relevant
- Data/migration implications where relevant
- Test strategy
- Rollout considerations
- Risk controls
- Implementation phases
The plan should clearly distinguish:
- execution truth
- artifact truth
- backup/snapshot truth
- recovery/evidence truth
- operator next action
Use those distinctions only where relevant to the feature.
## `tasks.md` Requirements
Tasks must be ordered, small, and verifiable.
Include:
- checkbox tasks
- phase grouping
- tests before or alongside implementation tasks where practical
- final validation tasks
- documentation/update tasks if needed
- explicit non-goals where useful
Avoid vague tasks such as:
```text
Clean up code
Refactor UI
Improve performance
Make it enterprise-ready
```
Prefer concrete tasks such as:
```text
- [ ] Add a feature test covering workspace isolation for <specific behavior>.
- [ ] Update <specific Filament page/resource> to display <specific state>.
- [ ] Add policy coverage for <specific capability>.
```
If exact file names are not known yet, phrase tasks as repo-verification tasks first rather than inventing file paths.
## Scope Control
If the requested feature implies multiple independent concerns, create one primary spec for the smallest valuable slice and add a `Follow-up spec candidates` section.
Examples of follow-up candidates:
- assigned findings
- pending approvals
- personal work queue
- notification delivery settings
- evidence pack export hardening
- operation monitoring refinements
- autonomous governance decision surfaces
Do not force all follow-up candidates into the primary spec.
## Final Response Requirements
After creating or updating the artifacts, respond with:
1. Created or updated spec directory
2. Files created or updated
3. Important repo-based adjustments made
4. Assumptions made
5. Open questions, if any
6. Recommended next manual analysis prompt
7. Explicit statement that no implementation was performed
Keep the final response concise, but include enough detail for the user to continue immediately.
## Required Next Manual Analysis Prompt
Always provide a ready-to-copy prompt like this, adapted to the created spec number and slug:
```markdown
Du bist ein Senior Staff Software Architect und Enterprise SaaS Reviewer.
Analysiere die neu erstellte Spec `<spec-number>-<slug>` streng repo-basiert.
Ziel:
Prüfe, ob `spec.md`, `plan.md` und `tasks.md` vollständig, konsistent, implementierbar und constitution-konform sind.
Wichtig:
- Keine Implementierung.
- Keine Codeänderungen.
- Keine Scope-Erweiterung.
- Prüfe nur gegen Repo-Wahrheit.
- Benenne konkrete Konflikte mit Dateien, Patterns, Datenflüssen oder bestehenden Specs.
- Schlage nur minimale Korrekturen an `spec.md`, `plan.md` und `tasks.md` vor.
- Wenn alles passt, gib eine klare Implementierungsfreigabe.
```
## Example Invocation
User:
```text
Nimm diesen Spec Candidate und mach daraus spec, plan und tasks in einem Rutsch. Danach mache ich die Analyse manuell.
```
Expected behavior:
1. Inspect constitution, templates, specs, roadmap, and candidate docs.
2. Determine the next valid spec number.
3. Create `spec.md`, `plan.md`, and `tasks.md` in the new spec directory.
4. Keep scope tight.
5. Do not implement.
6. Return the summary and next manual analysis prompt.

View File

@ -1,28 +1,30 @@
<!--
Sync Impact Report
- Version change: 2.8.0 -> 2.9.0
- Version change: 2.9.0 -> 2.10.0
- Modified principles:
- Added provider-boundary guardrail set under First Provider Is Not
Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
- Expanded Governance review expectations for provider-owned vs
platform-core boundaries
- Expanded Operations / Run Observability Standard so OperationRun
start UX is shared-contract-owned instead of surface-owned
- Expanded Governance review expectations for OperationRun-starting
features, explicit queued-notification policy, and bounded
exceptions
- Added sections:
- First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
the current first provider without allowing provider-specific
semantics to silently become platform-core truth; requires explicit
review of provider-owned vs platform-core seams and prefers bounded
extraction over speculative multi-provider frameworks
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
queued toast/link/event/message semantics, run/artifact deep links,
queued DB-notification policy, and tenant/workspace-safe operation
URL resolution behind one shared OperationRun UX layer
- Removed sections: None
- Templates requiring updates:
- .specify/templates/spec-template.md: add provider-boundary platform
core check ✅
- .specify/templates/plan-template.md: add provider-boundary planning
fields + constitution check ✅
- .specify/templates/tasks-template.md: add provider-boundary task
requirements ✅
- .specify/templates/checklist-template.md: add provider-boundary
review checks ✅
- .specify/templates/spec-template.md: add OperationRun UX Impact
section + start-contract prompts ✅
- .specify/templates/plan-template.md: add OperationRun UX Impact
planning section + constitution checks ✅
- .specify/templates/tasks-template.md: add central start-UX reuse,
queued-notification policy, and exception tasks ✅
- .specify/templates/checklist-template.md: add OperationRun start
UX review checks ✅
- docs/product/standards/README.md: refresh constitution index for
the new ops-UX contract ✅
- Commands checked:
- N/A `.specify/templates/commands/*.md` directory is not present
- Follow-up TODOs: None
@ -307,24 +309,57 @@ ### Operations / Run Observability Standard
even if implemented by multiple jobs/steps (“umbrella run”).
- “Single-row” runs MUST still use consistent counters (e.g., `total=1`, `processed=0|1`) and outcome derived from success/failure.
- Monitoring pages MUST be DB-only at render time (no external calls).
- Start surfaces MUST NOT perform remote work inline; they only: authorize, create/reuse run (dedupe), enqueue work,
confirm + “View run”.
- Start surfaces MUST NOT perform remote work inline and MUST NOT compose OperationRun start UX locally; they only:
authorize, create/reuse run (dedupe), enqueue work, and hand queued/start-state feedback to the shared
OperationRun Start UX Contract.
### OperationRun Start UX Contract (OPS-UX-START-001)
- OperationRun UX MUST be contract-driven, not surface-driven.
- Any feature that creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun` MUST use
the central OperationRun Start UX Contract.
- Filament Pages, Resources, Widgets, Livewire Components, Actions, and Services MUST NOT independently compose
OperationRun start UX from local pieces.
- The shared OperationRun UX layer MUST own:
- local start notification / toast
- `Open operation` / `View run` link
- artifact link such as `View snapshot`, `View pack`, or `View restore`
- run-enqueued browser event
- queued DB-notification decision
- dedupe / already-available / already-running messaging
- blocked / failed-to-start messaging
- tenant/workspace-safe operation URL resolution
- Feature surfaces MAY initiate `OperationRun`s, but they MUST NOT define their own OperationRun UX semantics.
- `OperationRun` lifecycle state remains the canonical execution truth.
- Queued DB notifications MUST remain explicit opt-in unless the active spec defines a different policy.
- Terminal `OperationRun` notifications MUST be emitted through the central OperationRun lifecycle mechanism.
- Any exception MUST include:
1. an explicit spec decision,
2. a documented architecture note,
3. a test or guard-test exception with rationale,
4. a follow-up migration decision if the exception is temporary.
- New OperationRun-starting features MUST include an `OperationRun UX Impact` section in the active spec or plan.
### Operations UX — 3-Surface Feedback (OPS-UX-055) (NON-NEGOTIABLE)
If a feature creates/reuses `OperationRun`, it MUST use exactly three feedback surfaces — no others:
If a feature creates/reuses `OperationRun`, its default feedback contract is exactly three surfaces.
Queued DB notifications are forbidden by default and MAY exist only when the active spec explicitly opts into them
through the OperationRun Start UX Contract:
1) Toast (intent only / queued-only)
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
- Feature code MUST NOT craft ad-hoc operation toasts.
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
- Queued toast copy, action links, artifact links, start-state browser events, and dedupe/start-failure messaging MUST be
produced by the shared OperationRun Start UX Contract, not by local surface code.
2) Progress (active awareness only)
- Live progress MUST exist only in:
- the global active-ops widget, and
- Monitoring → Operation Run Detail.
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
- Running DB notifications are forbidden.
- Determinate progress is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
- Determinate progress MUST be clamped to 0100. Otherwise render indeterminate + elapsed time.
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
@ -365,6 +400,10 @@ ### Ops-UX regression guards are mandatory (OPS-UX-GUARD-001)
The repo MUST include automated guards (Pest) that fail CI if:
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
- feature code bypasses the central OperationRun Start UX Contract for queued/start-state operation UX where the repo's
guardable patterns can detect it,
- feature code emits queued DB notifications for operations without explicit spec-driven opt-in through the shared
OperationRun UX layer,
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
- deprecated legacy operation notification classes are referenced again.
@ -1614,6 +1653,11 @@ ### Scope, Compliance, and Review Expectations
- 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 touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
- Specs and PRs that create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` MUST reuse the
central OperationRun Start UX Contract, keep queued DB notifications explicit opt-in unless the active spec states a
different policy, route terminal notifications through the lifecycle mechanism, include an `OperationRun UX Impact`
section in the active spec or plan, and document any temporary exception with an architecture note, test rationale,
and migration decision.
- 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.
@ -1631,4 +1675,4 @@ ### Versioning Policy (SemVer)
- **MINOR**: new principle/section or materially expanded guidance.
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
**Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24

View File

@ -32,6 +32,13 @@ ## Shared Pattern Reuse
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
## OperationRun Start UX Contract
- [ ] CHK019 The change explicitly says whether it creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`, and the required `OperationRun UX Impact` section exists when applicable.
- [ ] CHK020 Queued toast/link/artifact-link/browser-event/dedupe-or-blocked messaging and tenant/workspace-safe operation URL resolution are delegated to the shared OperationRun UX contract instead of local surface code.
- [ ] CHK021 Any queued DB notification is explicit opt-in in the active spec or plan, running DB notifications remain absent, and terminal notifications still flow through the central lifecycle mechanism.
- [ ] CHK022 Any exception records the explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision.
## Provider Boundary And Vocabulary
- [ ] CHK010 The change states whether any touched shared seam is provider-owned, platform-core, or mixed, and provider-specific semantics do not silently spread into platform-core contracts, taxonomy, identifiers, compare semantics, or operator vocabulary.

View File

@ -54,6 +54,18 @@ ## Shared Pattern & System Fit
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: [yes / no / N/A]
- **Central contract reused**: [shared OperationRun UX layer / `N/A`]
- **Delegated UX behaviors**: [queued toast / run link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Surface-owned behavior kept local**: [initiation inputs only / none / short explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception path**: [none / spec decision + architecture note + test rationale + temporary migration follow-up]
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
@ -79,7 +91,8 @@ ## Constitution Check
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
- Ops-UX 3-surface feedback: if `OperationRun` is used, feedback is exactly toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); no queued/running DB notifications
- OperationRun start UX: any feature that creates, queues, deduplicates, resumes, blocks, completes, or links `OperationRun` reuses the central OperationRun Start UX Contract; no local composition of queued toast/link/event/start-state messaging; `OperationRun UX Impact` is present in the active spec or plan
- Ops-UX 3-surface feedback: if `OperationRun` is used, default feedback is toast intent-only + progress surfaces + exactly-once terminal `OperationRunCompleted` (initiator-only); queued DB notifications remain explicit opt-in through the shared start UX contract; running DB notifications stay disallowed
- Ops-UX lifecycle: `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`); context-only updates allowed outside
- Ops-UX summary counts: `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress

View File

@ -47,6 +47,16 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: [yes/no]
- **Shared OperationRun UX contract/layer reused**: [Name it or `N/A`]
- **Delegated start/completion UX behaviors**: [queued toast / `Open operation` or `View run` link / artifact link / run-enqueued browser event / queued DB-notification decision / dedupe-or-blocked messaging / tenant/workspace-safe URL resolution / `N/A`]
- **Local surface-owned behavior that remains**: [initiation inputs only / none / bounded explanation]
- **Queued DB-notification policy**: [explicit opt-in / spec override / `N/A`]
- **Terminal notification path**: [central lifecycle mechanism / `N/A`]
- **Exception required?**: [none / explicit spec decision + architecture note + test or guard-test rationale + temporary migration follow-up]
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: [yes/no]
@ -263,12 +273,21 @@ ## Requirements *(mandatory)*
- and the exact minimal validation commands reviewers should run.
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
- explicitly state compliance with the Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification),
- explicitly state compliance with the default Ops-UX 3-surface feedback contract (toast intent-only, progress surfaces, terminal DB notification) and whether any queued DB notification is explicitly opted into,
- state that `OperationRun.status` / `OperationRun.outcome` transitions are service-owned (only via `OperationRunService`),
- describe how `summary_counts` keys/values comply with `OperationSummaryKeys::all()` and numeric-only rules,
- clarify scheduled/system-run behavior (initiator null → no terminal DB notification; audit is via Monitoring),
- list which regression guard tests are added/updated to keep these rules enforceable in CI.
**Constitution alignment (OPS-UX-START-001):** If this feature creates, queues, deduplicates, resumes, blocks, completes, or links to an `OperationRun`, the spec MUST:
- include the `OperationRun UX Impact` section,
- name the shared OperationRun UX contract/layer being reused,
- delegate queued toast/link/artifact-link/browser-event/queued-DB-notification/dedupe-or-blocked messaging/tenant-safe URL resolution to that shared path,
- keep local surface code limited to initiation inputs and operation-specific data capture,
- keep queued DB notifications explicit opt-in unless the spec intentionally defines a different policy,
- route terminal notifications through the central lifecycle mechanism,
- and document any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary follow-up migration decision.
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
- state which authorization plane(s) are involved (tenant/admin `/admin` + tenant-context `/admin/t/{tenant}/...` vs platform `/system`),
- ensure any cross-plane access is deny-as-not-found (404),

View File

@ -18,17 +18,22 @@ # Tasks: [FEATURE NAME]
- 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.
canonical `OperationRun`, and ensure “View run” links route to the canonical Monitoring hub through the shared OperationRun start UX path rather than local surface composition.
If security-relevant DB-only actions skip `OperationRun`, include tasks for `AuditLog` entries (before/after + actor + tenant).
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
without an `OperationRun`.
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
- reusing the central OperationRun Start UX Contract instead of composing local queued toast/link/event/dedupe/blocked/start-failure semantics,
- delegating `Open operation` / `View run`, artifact links, run-enqueued browser event, queued DB-notification policy, dedupe / already-available / already-running messaging, blocked / failed-to-start messaging, and tenant/workspace-safe URL resolution to the shared OperationRun UX layer,
- enforcing the Ops-UX 3-surface feedback contract (toast intent-only via `OperationUxPresenter`, progress only in widget + run detail, terminal notification is `OperationRunCompleted` exactly-once, initiator-only),
- ensuring no queued/running DB notifications exist anywhere for operations (no `sendToDatabase()` for queued/running/completion/abort in feature code),
- keeping queued DB notifications explicit opt-in in the active spec unless a different policy is intentionally approved, and ensuring running DB notifications do not exist,
- routing terminal notifications through the central lifecycle mechanism rather than feature-local notification code,
- ensuring `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
- adding/updating Ops-UX regression guards (Pest) that fail CI with actionable output (file + snippet) when these patterns regress,
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system).
- clarifying scheduled/system-run behavior (initiator null → no terminal DB notification; audit via Monitoring; tenant-wide alerting via Alerts system),
- documenting any exception with an explicit spec decision, architecture note, test or guard-test rationale, and temporary migration follow-up decision,
- and ensuring the active spec or plan contains an `OperationRun UX Impact` section.
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
- explicit 404 vs 403 semantics:

View File

@ -51,6 +51,9 @@
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Tenants\TenantInteractionLane;
use App\Support\Tenants\TenantLifecyclePresentation;
@ -317,7 +320,7 @@ public function content(Schema $schema): Schema
Section::make('Tenant')
->schema([
TextInput::make('entra_tenant_id')
->label('Entra Tenant ID (GUID)')
->label('Tenant ID (GUID)')
->required()
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
->rules(['uuid'])
@ -423,7 +426,8 @@ public function content(Schema $schema): Schema
->required()
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Directory (tenant) ID')
->label('Target scope ID')
->helperText('Provider-owned Microsoft tenant detail for this selected target scope.')
->disabled()
->dehydrated(false),
Toggle::make('uses_dedicated_override')
@ -461,6 +465,13 @@ public function content(Schema $schema): Schema
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
->maxLength(255)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
TextInput::make('new_connection.target_scope_id')
->label('Target scope ID')
->default(fn (): string => $this->currentManagedTenantRecord()?->tenant_id ?? '')
->disabled()
->dehydrated(false)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
->helperText('The provider connection will point to this tenant target scope.'),
TextInput::make('new_connection.connection_type')
->label('Connection type')
->default('Platform connection')
@ -657,7 +668,7 @@ public function content(Schema $schema): Schema
UnorderedList::make([
'Tenant status will be set to Active.',
'Backup, inventory, and compliance operations become available.',
'The provider connection will be used for all Graph API calls.',
'The provider connection will be used for provider API calls.',
]),
]),
Toggle::make('override_blocked')
@ -1593,6 +1604,7 @@ private function initializeWizardData(): void
if ($tenant instanceof Tenant) {
$this->data['entra_tenant_id'] ??= (string) $tenant->tenant_id;
$this->data['new_connection']['target_scope_id'] ??= (string) $tenant->tenant_id;
$this->data['environment'] ??= (string) ($tenant->environment ?? 'other');
$this->data['name'] ??= (string) $tenant->name;
$this->data['primary_domain'] ??= (string) ($tenant->domain ?? '');
@ -1676,14 +1688,56 @@ private function providerConnectionOptions(): array
}
return ProviderConnection::query()
->with('tenant')
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', $tenant->getKey())
->orderByDesc('is_default')
->orderBy('display_name')
->pluck('display_name', 'id')
->get()
->mapWithKeys(fn (ProviderConnection $connection): array => [
(int) $connection->getKey() => sprintf(
'%s — %s',
(string) $connection->display_name,
$this->providerConnectionTargetScopeSummary($connection),
),
])
->all();
}
private function providerConnectionTargetScopeSummary(ProviderConnection $connection): string
{
try {
return ProviderConnectionSurfaceSummary::forConnection($connection)->targetScopeSummary();
} catch (InvalidArgumentException) {
return 'Target scope needs review';
}
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
private function providerConnectionTargetScopeAuditMetadata(ProviderConnection $connection, array $extra = []): array
{
try {
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($connection, $extra);
} catch (InvalidArgumentException) {
return array_merge([
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'target_scope' => [
'provider' => (string) $connection->provider,
'scope_kind' => ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
'scope_identifier' => (string) $connection->entra_tenant_id,
'scope_display_name' => (string) ($connection->tenant?->name ?? $connection->display_name ?? $connection->entra_tenant_id),
'shared_label' => 'Target scope',
'shared_help_text' => 'The platform scope this provider connection represents.',
],
'provider_identity_context' => [],
], $extra);
}
}
private function verificationStatusLabel(): string
{
return BadgeCatalog::spec(
@ -2599,12 +2653,11 @@ public function selectProviderConnection(int $providerConnectionId): void
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
context: [
'metadata' => [
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(),
],
]),
],
actor: $user,
status: 'success',
@ -2657,6 +2710,22 @@ public function createProviderConnection(array $data): void
abort(422);
}
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) $tenant->tenant_id,
scopeDisplayName: $displayName,
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) $tenant->tenant_id,
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'new_connection.target_scope_id' => $targetScope['message'],
]);
}
if ($usesDedicatedCredential) {
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
}
@ -2733,14 +2802,11 @@ public function createProviderConnection(array $data): void
tenant: $tenant,
action: 'provider_connection.created',
context: [
'metadata' => [
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'connection_type' => $connection->connection_type->value,
'source' => 'managed_tenant_onboarding_wizard.create',
],
]),
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -2756,15 +2822,12 @@ public function createProviderConnection(array $data): void
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'from_connection_type' => $previousConnectionType->value,
'to_connection_type' => $connection->connection_type->value,
'source' => 'managed_tenant_onboarding_wizard.create',
],
]),
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -4304,15 +4367,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
tenant: $this->managedTenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'from_connection_type' => $existingType->value,
'to_connection_type' => $targetType->value,
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
],
]),
],
actorId: (int) $user->getKey(),
actorEmail: (string) $user->email,
@ -4328,15 +4388,12 @@ public function updateSelectedProviderConnectionInline(int $providerConnectionId
tenant: $this->managedTenant,
action: 'provider_connection.updated',
context: [
'metadata' => [
'metadata' => $this->providerConnectionTargetScopeAuditMetadata($connection, [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'fields' => $changedFields,
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
'connection_type' => $targetType->value,
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
],
]),
],
actorId: (int) $user->getKey(),
actorEmail: (string) $user->email,

View File

@ -26,6 +26,8 @@
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
@ -50,6 +52,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Str;
use InvalidArgumentException;
use UnitEnum;
class ProviderConnectionResource extends Resource
@ -484,6 +487,62 @@ private static function verificationStatusLabelFromState(mixed $state): string
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
}
private static function targetScopeHelpText(): string
{
return 'The platform scope this provider connection represents. For Microsoft, use the tenant directory ID for that scope.';
}
private static function targetScopeSummary(?ProviderConnection $record): string
{
if (! $record instanceof ProviderConnection) {
return 'Target scope is set when this connection is saved.';
}
try {
return ProviderConnectionSurfaceSummary::forConnection($record)->targetScopeSummary();
} catch (InvalidArgumentException) {
return 'Target scope needs review';
}
}
private static function providerIdentityContext(?ProviderConnection $record): ?string
{
if (! $record instanceof ProviderConnection) {
return null;
}
try {
return ProviderConnectionSurfaceSummary::forConnection($record)->contextualIdentityLine();
} catch (InvalidArgumentException) {
return null;
}
}
/**
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
public static function targetScopeAuditMetadata(ProviderConnection $record, array $extra = []): array
{
try {
return app(ProviderConnectionTargetScopeNormalizer::class)->auditMetadataForConnection($record, $extra);
} catch (InvalidArgumentException) {
return array_merge([
'provider_connection_id' => (int) $record->getKey(),
'provider' => (string) $record->provider,
'target_scope' => [
'provider' => (string) $record->provider,
'scope_kind' => 'tenant',
'scope_identifier' => (string) $record->entra_tenant_id,
'scope_display_name' => (string) ($record->tenant?->name ?? $record->display_name ?? $record->entra_tenant_id),
'shared_label' => 'Target scope',
'shared_help_text' => static::targetScopeHelpText(),
],
'provider_identity_context' => [],
], $extra);
}
}
public static function form(Schema $schema): Schema
{
return $schema
@ -496,11 +555,17 @@ public static function form(Schema $schema): Schema
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Entra tenant ID')
->label('Target scope ID')
->required()
->maxLength(255)
->helperText(static::targetScopeHelpText())
->validationAttribute('target scope ID')
->disabled(fn (): bool => ! static::hasTenantCapability(Capabilities::PROVIDER_MANAGE))
->rules(['uuid']),
Placeholder::make('target_scope_display')
->label('Target scope')
->content(fn (?ProviderConnection $record): string => static::targetScopeSummary($record))
->visible(fn (?ProviderConnection $record): bool => $record instanceof ProviderConnection),
Placeholder::make('connection_type_display')
->label('Connection type')
->content(fn (?ProviderConnection $record): string => static::providerConnectionTypeLabel($record)),
@ -563,8 +628,9 @@ public static function infolist(Schema $schema): Schema
->label('Display name'),
Infolists\Components\TextEntry::make('provider')
->label('Provider'),
Infolists\Components\TextEntry::make('entra_tenant_id')
->label('Entra tenant ID')
Infolists\Components\TextEntry::make('target_scope')
->label('Target scope')
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
->copyable(),
Infolists\Components\TextEntry::make('connection_type')
->label('Connection type')
@ -614,6 +680,11 @@ public static function infolist(Schema $schema): Schema
->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
->tooltip(fn (ProviderConnection $record): ?string => static::migrationReviewDescription($record)),
Infolists\Components\TextEntry::make('provider_identity_context')
->label('Provider identity details')
->state(fn (ProviderConnection $record): ?string => static::providerIdentityContext($record))
->placeholder('n/a')
->columnSpanFull(),
Infolists\Components\TextEntry::make('last_error_reason_code')
->label('Last error reason')
->placeholder('n/a'),
@ -671,9 +742,15 @@ public static function table(Table $table): Table
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
Tables\Columns\TextColumn::make('provider')
->label('Provider')
->formatStateUsing(fn (?string $state): string => Str::headline((string) $state)),
Tables\Columns\TextColumn::make('target_scope')
->label('Target scope')
->state(fn (ProviderConnection $record): string => static::targetScopeSummary($record))
->copyable(),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Microsoft tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('connection_type')
->label('Connection type')
->badge()
@ -949,10 +1026,7 @@ public static function makeSetDefaultAction(): Actions\Action
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
'metadata' => static::targetScopeAuditMetadata($record),
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1014,15 +1088,12 @@ public static function makeEnableDedicatedOverrideAction(string $source, ?string
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'metadata' => static::targetScopeAuditMetadata($record, [
'from_connection_type' => ProviderConnectionType::Platform->value,
'to_connection_type' => ProviderConnectionType::Dedicated->value,
'client_id' => (string) $data['client_id'],
'source' => $source,
],
]),
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1161,14 +1232,11 @@ public static function makeRevertToPlatformAction(string $source, ?string $modal
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'provider_connection_id' => (int) $record->getKey(),
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'metadata' => static::targetScopeAuditMetadata($record, [
'from_connection_type' => ProviderConnectionType::Dedicated->value,
'to_connection_type' => ProviderConnectionType::Platform->value,
'source' => $source,
],
]),
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1233,14 +1301,12 @@ public static function makeEnableConnectionAction(): Actions\Action
tenant: $tenant,
action: 'provider_connection.enabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'metadata' => static::targetScopeAuditMetadata($record, [
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials,
],
]),
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -1302,12 +1368,10 @@ public static function makeDisableConnectionAction(): Actions\Action
tenant: $tenant,
action: 'provider_connection.disabled',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'metadata' => static::targetScopeAuditMetadata($record, [
'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled',
],
]),
],
actorId: $actorId,
actorEmail: $actorEmail,

View File

@ -9,9 +9,12 @@
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\ProviderVerificationStatus;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
class CreateProviderConnection extends CreateRecord
{
@ -28,6 +31,21 @@ protected function mutateFormDataBeforeCreate(array $data): array
}
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
scopeDisplayName: (string) ($data['display_name'] ?? ''),
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'entra_tenant_id' => $targetScope['message'],
]);
}
return [
'workspace_id' => (int) $tenant->workspace_id,
@ -70,11 +88,9 @@ protected function afterCreate(): void
tenant: $tenant,
action: 'provider_connection.created',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
'connection_type' => $record->connection_type->value,
],
]),
],
actorId: $actorId,
actorEmail: $actorEmail,

View File

@ -19,6 +19,8 @@
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Actions\Action;
@ -26,6 +28,7 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
class EditProviderConnection extends EditRecord
{
@ -77,6 +80,22 @@ protected function mutateFormDataBeforeSave(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
unset($data['is_default']);
$targetScope = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: (string) ($data['entra_tenant_id'] ?? ''),
scopeDisplayName: (string) ($data['display_name'] ?? ''),
providerSpecificIdentity: [
'microsoft_tenant_id' => (string) ($data['entra_tenant_id'] ?? ''),
],
);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
throw ValidationException::withMessages([
'entra_tenant_id' => $targetScope['message'],
]);
}
return $data;
}
@ -119,11 +138,9 @@ protected function afterSave(): void
tenant: $tenant,
action: 'provider_connection.updated',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
'fields' => $changedFields,
],
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record, [
'fields' => app(ProviderConnectionTargetScopeNormalizer::class)->auditFieldNames($changedFields),
]),
],
actorId: $actorId,
actorEmail: $actorEmail,
@ -139,10 +156,7 @@ protected function afterSave(): void
tenant: $tenant,
action: 'provider_connection.default_set',
context: [
'metadata' => [
'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id,
],
'metadata' => ProviderConnectionResource::targetScopeAuditMetadata($record),
],
actorId: $actorId,
actorEmail: $actorEmail,

View File

@ -237,12 +237,12 @@ private function resolveTenantForCreateAction(): ?Tenant
public function getTableEmptyStateHeading(): ?string
{
return 'No Microsoft connections found';
return 'No provider connections found';
}
public function getTableEmptyStateDescription(): ?string
{
return 'Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.';
return 'Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.';
}
public function getTableEmptyStateActions(): array

View File

@ -452,6 +452,11 @@ private function logVerificationResult(
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
),
'reason_code' => $reasonCode,
'operation_run_id' => (int) $run->getKey(),
'previous_consent_status' => $previousConsentStatus,

View File

@ -4,11 +4,19 @@
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
final class PlatformProviderIdentityResolver
{
public function resolve(string $tenantContext): ProviderIdentityResolution
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
*/
public function resolve(
string $tenantContext,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): ProviderIdentityResolution {
$targetTenant = trim($tenantContext);
$clientId = trim((string) config('graph.client_id'));
$clientSecret = trim((string) config('graph.client_secret'));
@ -22,6 +30,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: 'Provider connection is missing target tenant scope.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
@ -32,6 +42,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityMissing,
message: 'Platform app identity is not configured.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
@ -42,6 +54,8 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
credentialSource: 'platform_config',
reasonCode: ProviderReasonCodes::PlatformIdentityIncomplete,
message: 'Platform app identity is incomplete.',
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
@ -53,6 +67,13 @@ public function resolve(string $tenantContext): ProviderIdentityResolution
clientSecret: $clientSecret,
authorityTenant: $authorityTenant !== '' ? $authorityTenant : 'organizations',
redirectUri: $redirectUri,
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails !== []
? array_values(array_merge($contextualIdentityDetails, array_filter([
ProviderIdentityContextMetadata::authorityTenant($authorityTenant !== '' ? $authorityTenant : 'organizations'),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
])))
: [],
);
}
}

View File

@ -26,7 +26,7 @@ public function enableDedicatedOverride(
$clientSecret = trim($clientSecret);
if ($clientId === '' || $clientSecret === '') {
throw new InvalidArgumentException('Dedicated client_id and client_secret are required.');
throw new InvalidArgumentException('Dedicated app (client) ID and client secret are required.');
}
return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection {

View File

@ -4,25 +4,38 @@
use App\Models\ProviderConnection;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
final class ProviderConnectionResolution
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
*/
private function __construct(
public readonly bool $resolved,
public readonly ?ProviderConnection $connection,
public readonly ?string $reasonCode,
public readonly ?string $extensionReasonCode,
public readonly ?string $message,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly array $contextualIdentityDetails,
) {}
public static function resolved(ProviderConnection $connection): self
{
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
return new self(
resolved: true,
connection: $connection,
reasonCode: null,
extensionReasonCode: null,
message: null,
targetScope: $normalizer->descriptorForConnection($connection),
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
);
}
@ -32,12 +45,29 @@ public static function blocked(
?string $extensionReasonCode = null,
?ProviderConnection $connection = null,
): self {
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = null;
$contextualIdentityDetails = [];
if ($connection instanceof ProviderConnection) {
$normalization = $normalizer->normalizeConnection($connection);
$descriptor = $normalization['target_scope'] ?? null;
if ($descriptor instanceof ProviderConnectionTargetScopeDescriptor) {
$targetScope = $descriptor;
$contextualIdentityDetails = $normalizer->contextualIdentityDetailsForConnection($connection);
}
}
return new self(
resolved: false,
connection: $connection,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
extensionReasonCode: $extensionReasonCode,
message: $message,
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
);
}

View File

@ -6,11 +6,13 @@
use App\Models\Tenant;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
final class ProviderConnectionResolver
{
public function __construct(
private readonly ProviderIdentityResolver $identityResolver,
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
) {}
public function resolveDefault(Tenant $tenant, string $provider): ProviderConnectionResolution
@ -63,11 +65,19 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon
);
}
if ($connection->entra_tenant_id === null || trim((string) $connection->entra_tenant_id) === '') {
$targetScope = $this->targetScopeNormalizer->normalizeConnection($connection);
if ($targetScope['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
$failureCode = $targetScope['failure_code'] ?? ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT;
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is missing target tenant scope.',
'ext.connection_tenant_missing',
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
? ProviderReasonCodes::ProviderBindingUnsupported
: ProviderReasonCodes::ProviderConnectionInvalid,
$targetScope['message'] ?? 'Provider connection target scope is invalid.',
$failureCode === ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION
? 'ext.connection_scope_unsupported'
: 'ext.connection_scope_missing',
$connection,
);
}

View File

@ -6,10 +6,16 @@
use App\Services\Providers\Contracts\HealthResult;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\ProviderVerificationStatus;
final class ProviderConnectionStateProjector
{
public function surfaceSummary(ProviderConnection $connection): ProviderConnectionSurfaceSummary
{
return ProviderConnectionSurfaceSummary::forConnection($connection);
}
/**
* @return array{
* consent_status: ProviderConsentStatus,

View File

@ -5,6 +5,8 @@
use App\Models\ProviderConnection;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use Illuminate\Support\Str;
use RuntimeException;
final class ProviderGateway
{
@ -53,6 +55,17 @@ public function request(ProviderConnection $connection, string $method, string $
*/
public function graphOptions(ProviderConnection $connection, array $overrides = []): array
{
return $this->identityResolver->resolve($connection)->graphOptions($overrides);
$resolution = $this->identityResolver->resolve($connection);
if (! $resolution->resolved || $resolution->effectiveClientId === null || $resolution->clientSecret === null) {
throw new RuntimeException($resolution->message ?? 'Provider identity could not be resolved.');
}
return array_merge([
'tenant' => $resolution->tenantContext,
'client_id' => $resolution->effectiveClientId,
'client_secret' => $resolution->clientSecret,
'client_request_id' => (string) Str::uuid(),
], $overrides);
}
}

View File

@ -4,11 +4,14 @@
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Support\Str;
use RuntimeException;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderIdentityContextMetadata;
final class ProviderIdentityResolution
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
*/
private function __construct(
public readonly bool $resolved,
public readonly ProviderConnectionType $connectionType,
@ -20,6 +23,8 @@ private function __construct(
public readonly ?string $redirectUri,
public readonly ?string $reasonCode,
public readonly ?string $message,
public readonly ?ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly array $contextualIdentityDetails,
) {}
public static function resolved(
@ -30,6 +35,8 @@ public static function resolved(
?string $clientSecret,
?string $authorityTenant,
?string $redirectUri,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): self {
return new self(
resolved: true,
@ -42,6 +49,10 @@ public static function resolved(
redirectUri: $redirectUri,
reasonCode: null,
message: null,
targetScope: $targetScope ?? self::targetScopeFromContext($tenantContext),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext, $authorityTenant, $redirectUri),
);
}
@ -51,6 +62,8 @@ public static function blocked(
string $credentialSource,
string $reasonCode,
?string $message = null,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): self {
return new self(
resolved: false,
@ -63,29 +76,47 @@ public static function blocked(
redirectUri: null,
reasonCode: ProviderReasonCodes::isKnown($reasonCode) ? $reasonCode : ProviderReasonCodes::UnknownError,
message: $message,
targetScope: $targetScope ?? (trim($tenantContext) !== '' ? self::targetScopeFromContext($tenantContext) : null),
contextualIdentityDetails: $contextualIdentityDetails !== []
? $contextualIdentityDetails
: self::contextualIdentityDetails($tenantContext),
);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
public function graphOptions(array $overrides = []): array
{
if (! $this->resolved || $this->effectiveClientId === null || $this->clientSecret === null) {
throw new RuntimeException($this->message ?? 'Provider identity could not be resolved.');
}
return array_merge([
'tenant' => $this->tenantContext,
'client_id' => $this->effectiveClientId,
'client_secret' => $this->clientSecret,
'client_request_id' => (string) Str::uuid(),
], $overrides);
}
public function effectiveReasonCode(): string
{
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
}
private static function targetScopeFromContext(string $tenantContext): ProviderConnectionTargetScopeDescriptor
{
$identifier = trim($tenantContext) !== '' ? trim($tenantContext) : 'organizations';
return ProviderConnectionTargetScopeDescriptor::fromInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: $identifier,
scopeDisplayName: $identifier,
);
}
/**
* @return list<ProviderIdentityContextMetadata>
*/
private static function contextualIdentityDetails(
string $tenantContext,
?string $authorityTenant = null,
?string $redirectUri = null,
): array {
$details = [
ProviderIdentityContextMetadata::microsoftTenantId($tenantContext),
ProviderIdentityContextMetadata::authorityTenant($authorityTenant),
ProviderIdentityContextMetadata::redirectUri($redirectUri),
];
return array_values(array_filter(
$details,
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
));
}
}

View File

@ -7,6 +7,8 @@
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialSource;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use InvalidArgumentException;
use RuntimeException;
@ -15,12 +17,16 @@ final class ProviderIdentityResolver
public function __construct(
private readonly PlatformProviderIdentityResolver $platformResolver,
private readonly CredentialManager $credentials,
private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer,
) {}
public function resolve(ProviderConnection $connection): ProviderIdentityResolution
{
$tenantContext = trim((string) $connection->entra_tenant_id);
$connectionType = $this->resolveConnectionType($connection);
$targetScopeResult = $this->targetScopeNormalizer->normalizeConnection($connection);
$targetScope = $targetScopeResult['target_scope'] ?? null;
$contextualIdentityDetails = $this->targetScopeNormalizer->contextualIdentityDetailsForConnection($connection);
if ($connectionType === null) {
return ProviderIdentityResolution::blocked(
@ -29,16 +35,20 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
credentialSource: 'unknown',
reasonCode: ProviderReasonCodes::ProviderConnectionTypeInvalid,
message: 'Provider connection type is invalid.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
if ($tenantContext === '') {
if ($targetScopeResult['status'] !== ProviderConnectionTargetScopeNormalizer::STATUS_NORMALIZED) {
return ProviderIdentityResolution::blocked(
connectionType: $connectionType,
tenantContext: 'organizations',
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::DedicatedManual->value,
reasonCode: ProviderReasonCodes::ProviderConnectionInvalid,
message: 'Provider connection is missing target tenant scope.',
message: $targetScopeResult['message'] ?? 'Provider connection target scope is invalid.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
@ -49,14 +59,25 @@ public function resolve(ProviderConnection $connection): ProviderIdentityResolut
credentialSource: $connectionType === ProviderConnectionType::Platform ? 'platform_config' : ProviderCredentialSource::LegacyMigrated->value,
reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired,
message: 'Provider connection requires migration review before use.',
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
if ($connectionType === ProviderConnectionType::Platform) {
return $this->platformResolver->resolve($tenantContext);
return $this->platformResolver->resolve(
tenantContext: $tenantContext,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
return $this->resolveDedicatedIdentity($connection, $tenantContext);
return $this->resolveDedicatedIdentity(
connection: $connection,
tenantContext: $tenantContext,
targetScope: $targetScope instanceof ProviderConnectionTargetScopeDescriptor ? $targetScope : null,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
private function resolveConnectionType(ProviderConnection $connection): ?ProviderConnectionType
@ -77,6 +98,8 @@ private function resolveConnectionType(ProviderConnection $connection): ?Provide
private function resolveDedicatedIdentity(
ProviderConnection $connection,
string $tenantContext,
?ProviderConnectionTargetScopeDescriptor $targetScope = null,
array $contextualIdentityDetails = [],
): ProviderIdentityResolution {
try {
$credentials = $this->credentials->getClientCredentials($connection);
@ -89,6 +112,8 @@ private function resolveDedicatedIdentity(
? ProviderReasonCodes::DedicatedCredentialInvalid
: ProviderReasonCodes::DedicatedCredentialMissing,
message: $exception->getMessage(),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
);
}
@ -100,6 +125,8 @@ private function resolveDedicatedIdentity(
clientSecret: $credentials['client_secret'],
authorityTenant: $tenantContext,
redirectUri: trim((string) route('admin.consent.callback')),
targetScope: $targetScope,
contextualIdentityDetails: $contextualIdentityDetails,
);
}

View File

@ -7,44 +7,48 @@
final class ProviderOperationRegistry
{
public const string BINDING_ACTIVE = 'active';
public const string BINDING_UNSUPPORTED = 'unsupported';
/**
* @return array<string, array{provider: string, module: string, label: string, required_capability: string}>
* @return array<string, array{operation_type: string, module: string, label: string, required_capability: string}>
*/
public function all(): array
public function definitions(): array
{
return [
'provider.connection.check' => [
'provider' => 'microsoft',
'operation_type' => 'provider.connection.check',
'module' => 'health_check',
'label' => 'Provider connection check',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'inventory_sync' => [
'provider' => 'microsoft',
'operation_type' => 'inventory_sync',
'module' => 'inventory',
'label' => 'Inventory sync',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'compliance.snapshot' => [
'provider' => 'microsoft',
'operation_type' => 'compliance.snapshot',
'module' => 'compliance',
'label' => 'Compliance snapshot',
'required_capability' => Capabilities::PROVIDER_RUN,
],
'restore.execute' => [
'provider' => 'microsoft',
'operation_type' => 'restore.execute',
'module' => 'restore',
'label' => 'Restore execution',
'required_capability' => Capabilities::TENANT_MANAGE,
],
'entra_group_sync' => [
'provider' => 'microsoft',
'operation_type' => 'entra_group_sync',
'module' => 'directory_groups',
'label' => 'Directory groups sync',
'required_capability' => Capabilities::TENANT_SYNC,
],
'directory_role_definitions.sync' => [
'provider' => 'microsoft',
'operation_type' => 'directory_role_definitions.sync',
'module' => 'directory_role_definitions',
'label' => 'Role definitions sync',
'required_capability' => Capabilities::TENANT_MANAGE,
@ -52,19 +56,78 @@ public function all(): array
];
}
public function isAllowed(string $operationType): bool
/**
* @return array<string, array{operation_type: string, module: string, label: string, required_capability: string}>
*/
public function all(): array
{
return array_key_exists($operationType, $this->all());
return $this->definitions();
}
/**
* @return array{provider: string, module: string, label: string, required_capability: string}
* @return array<string, array<string, array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}>>
*/
public function providerBindings(): array
{
return [
'provider.connection.check' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'provider.connection.check',
handlerNotes: 'Uses the current Microsoft Graph provider connection health-check workflow.',
exceptionNotes: 'Current-release provider binding remains Microsoft-only until a real second provider case exists.',
),
],
'inventory_sync' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'inventory_sync',
handlerNotes: 'Uses the current Microsoft Intune inventory sync workflow.',
exceptionNotes: 'Inventory collection is currently Microsoft Intune-specific provider behavior.',
),
],
'compliance.snapshot' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'compliance.snapshot',
handlerNotes: 'Uses the current Microsoft compliance snapshot workflow.',
exceptionNotes: 'Compliance snapshot runtime remains bounded to the Microsoft provider.',
),
],
'restore.execute' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'restore.execute',
handlerNotes: 'Uses the current Microsoft restore execution workflow.',
exceptionNotes: 'Restore execution remains Microsoft-only and must preserve dry-run and audit safeguards.',
),
],
'entra_group_sync' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'entra_group_sync',
handlerNotes: 'Uses the current Microsoft Entra group synchronization workflow.',
exceptionNotes: 'The operation type keeps current Entra vocabulary until the identity-neutrality follow-up.',
),
],
'directory_role_definitions.sync' => [
'microsoft' => $this->activeMicrosoftBinding(
operationType: 'directory_role_definitions.sync',
handlerNotes: 'Uses the current Microsoft directory role definition synchronization workflow.',
exceptionNotes: 'Directory role definitions are Microsoft-owned provider semantics.',
),
],
];
}
public function isAllowed(string $operationType): bool
{
return array_key_exists(trim($operationType), $this->definitions());
}
/**
* @return array{operation_type: string, module: string, label: string, required_capability: string}
*/
public function get(string $operationType): array
{
$operationType = trim($operationType);
$definition = $this->all()[$operationType] ?? null;
$definition = $this->definitions()[$operationType] ?? null;
if (! is_array($definition)) {
throw new InvalidArgumentException("Unknown provider operation type: {$operationType}");
@ -72,4 +135,85 @@ public function get(string $operationType): array
return $definition;
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}|null
*/
public function bindingFor(string $operationType, string $provider): ?array
{
$operationType = trim($operationType);
$provider = trim($provider);
if ($operationType === '' || $provider === '') {
return null;
}
$bindings = $this->providerBindings()[$operationType] ?? [];
return $bindings[$provider] ?? null;
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}|null
*/
public function activeBindingFor(string $operationType): ?array
{
$operationType = trim($operationType);
$bindings = $this->providerBindings()[$operationType] ?? [];
foreach ($bindings as $binding) {
if (($binding['binding_status'] ?? null) === self::BINDING_ACTIVE) {
return $binding;
}
}
return null;
}
/**
* @return array{
* definition: array{operation_type: string, module: string, label: string, required_capability: string},
* binding: array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
* }
*/
public function boundaryOperation(string $operationType, ?string $provider = null): array
{
$definition = $this->get($operationType);
$binding = is_string($provider) && trim($provider) !== ''
? $this->bindingFor($operationType, $provider)
: $this->activeBindingFor($operationType);
return [
'definition' => $definition,
'binding' => $binding ?? $this->unsupportedBinding($operationType, $provider ?? 'unknown'),
];
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
public function unsupportedBinding(string $operationType, string $provider): array
{
return [
'operation_type' => trim($operationType),
'provider' => trim($provider) !== '' ? trim($provider) : 'unknown',
'binding_status' => self::BINDING_UNSUPPORTED,
'handler_notes' => 'No explicit provider binding exists for this operation/provider combination.',
'exception_notes' => 'Unsupported combinations must block explicitly instead of inheriting Microsoft behavior.',
];
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
private function activeMicrosoftBinding(string $operationType, string $handlerNotes, string $exceptionNotes): array
{
return [
'operation_type' => $operationType,
'provider' => 'microsoft',
'binding_status' => self::BINDING_ACTIVE,
'handler_notes' => $handlerNotes,
'exception_notes' => $exceptionNotes,
];
}
}

View File

@ -42,26 +42,47 @@ public function start(
array $extraContext = [],
): ProviderOperationStartResult {
$definition = $this->registry->get($operationType);
$binding = $this->resolveProviderBinding($operationType, $connection);
if (($binding['binding_status'] ?? null) !== ProviderOperationRegistry::BINDING_ACTIVE) {
return $this->startBlocked(
tenant: $tenant,
operationType: $operationType,
provider: (string) ($binding['provider'] ?? 'unknown'),
module: (string) $definition['module'],
reasonCode: ProviderReasonCodes::ProviderBindingUnsupported,
extensionReasonCode: 'ext.provider_binding_missing',
reasonMessage: 'No explicit provider binding supports this operation/provider combination.',
connection: $connection,
initiator: $initiator,
extraContext: array_merge($extraContext, [
'provider_binding' => $this->bindingContext($binding),
]),
);
}
$resolution = $connection instanceof ProviderConnection
? $this->resolver->validateConnection($tenant, (string) $definition['provider'], $connection)
: $this->resolver->resolveDefault($tenant, (string) $definition['provider']);
? $this->resolver->validateConnection($tenant, (string) $binding['provider'], $connection)
: $this->resolver->resolveDefault($tenant, (string) $binding['provider']);
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
return $this->startBlocked(
tenant: $tenant,
operationType: $operationType,
provider: (string) $definition['provider'],
provider: (string) $binding['provider'],
module: (string) $definition['module'],
reasonCode: $resolution->effectiveReasonCode(),
extensionReasonCode: $resolution->extensionReasonCode,
reasonMessage: $resolution->message,
connection: $resolution->connection ?? $connection,
initiator: $initiator,
extraContext: $extraContext,
extraContext: array_merge($extraContext, [
'provider_binding' => $this->bindingContext($binding),
]),
);
}
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $resolution): ProviderOperationStartResult {
return DB::transaction(function () use ($tenant, $operationType, $dispatcher, $initiator, $extraContext, $definition, $binding, $resolution): ProviderOperationStartResult {
$connection = $resolution->connection;
if (! $connection instanceof ProviderConnection) {
@ -114,6 +135,7 @@ public function start(
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
'provider' => $lockedConnection->provider,
'module' => $definition['module'],
'provider_binding' => $this->bindingContext($binding),
'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
@ -235,6 +257,36 @@ private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
$dispatcher();
}
/**
* @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
private function resolveProviderBinding(string $operationType, ?ProviderConnection $connection): array
{
if ($connection instanceof ProviderConnection) {
$provider = trim((string) $connection->provider);
return $this->registry->bindingFor($operationType, $provider)
?? $this->registry->unsupportedBinding($operationType, $provider);
}
return $this->registry->activeBindingFor($operationType)
?? $this->registry->unsupportedBinding($operationType, 'unknown');
}
/**
* @param array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string} $binding
* @return array{provider: string, binding_status: string, handler_notes: string, exception_notes: string}
*/
private function bindingContext(array $binding): array
{
return [
'provider' => (string) $binding['provider'],
'binding_status' => (string) $binding['binding_status'],
'handler_notes' => (string) $binding['handler_notes'],
'exception_notes' => (string) $binding['exception_notes'],
];
}
/**
* @param array<string, mixed> $extraContext
*/

View File

@ -119,6 +119,11 @@ public function providerConnectionCheckUsingConnection(
'connection_type' => $identity->connectionType->value,
'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId,
'target_scope' => $identity->targetScope?->toArray(),
'provider_identity_context' => array_map(
static fn ($detail): array => $detail->toArray(),
$identity->contextualIdentityDetails,
),
],
]),
);

View File

@ -0,0 +1,194 @@
<?php
namespace App\Support\Providers\Boundary;
use InvalidArgumentException;
final class ProviderBoundaryCatalog
{
public const string STATUS_ALLOWED = 'allowed';
public const string STATUS_REVIEW_REQUIRED = 'review_required';
public const string STATUS_BLOCKED = 'blocked';
public const string VIOLATION_NONE = 'none';
public const string VIOLATION_PLATFORM_CORE_PROVIDER_LEAK = 'platform_core_provider_leak';
public const string VIOLATION_UNDECLARED_EXCEPTION = 'undeclared_exception';
public const string VIOLATION_MISSING_PROVIDER_BINDING = 'missing_provider_binding';
public const string VIOLATION_PROVIDER_BINDING_AS_PRIMARY_TRUTH = 'provider_binding_as_primary_truth';
/**
* @return array<string, ProviderBoundarySeam>
*/
public function all(): array
{
$seams = config('provider_boundaries.seams', []);
if (! is_array($seams)) {
throw new InvalidArgumentException('Provider boundary seam catalog must be an array.');
}
$catalog = [];
foreach ($seams as $key => $attributes) {
if (! is_string($key) || ! is_array($attributes)) {
throw new InvalidArgumentException('Provider boundary seam catalog entries must be keyed arrays.');
}
$catalog[$key] = ProviderBoundarySeam::fromConfig($key, $attributes);
}
ksort($catalog);
return $catalog;
}
public function get(string $key): ProviderBoundarySeam
{
$normalizedKey = trim($key);
$seam = $this->all()[$normalizedKey] ?? null;
if (! $seam instanceof ProviderBoundarySeam) {
throw new InvalidArgumentException("Unknown provider boundary seam: {$normalizedKey}");
}
return $seam;
}
public function has(string $key): bool
{
return array_key_exists(trim($key), $this->all());
}
/**
* @return array{
* status: string,
* seam_key: string,
* file_path: string,
* violation_code: string,
* message: string,
* suggested_follow_up: string
* }
*/
public function evaluateChange(
string $seamKey,
string $filePath,
ProviderBoundaryOwner|string $proposedOwner,
array $providerSpecificTerms = [],
bool $introducesNewBinding = false,
): array {
$seam = $this->get($seamKey);
$owner = is_string($proposedOwner)
? ProviderBoundaryOwner::tryFrom($proposedOwner)
: $proposedOwner;
if (! $owner instanceof ProviderBoundaryOwner) {
throw new InvalidArgumentException('Proposed provider boundary owner is invalid.');
}
$providerSpecificTerms = $this->normalizeTerms($providerSpecificTerms);
if ($introducesNewBinding && $seam->isPlatformCore() && $owner === ProviderBoundaryOwner::PlatformCore) {
return $this->result(
status: self::STATUS_BLOCKED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_PROVIDER_BINDING_AS_PRIMARY_TRUTH,
message: 'Provider binding metadata must stay explicit and secondary to the platform-core operation definition.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
);
}
if ($seam->isProviderOwned()) {
return $this->result(
status: self::STATUS_ALLOWED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_NONE,
message: 'Provider-specific semantics are allowed inside this provider-owned seam.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_NONE,
);
}
if ($providerSpecificTerms === []) {
return $this->result(
status: self::STATUS_ALLOWED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_NONE,
message: 'The platform-core seam does not introduce provider-specific terms.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_NONE,
);
}
$undocumentedTerms = array_values(array_filter(
$providerSpecificTerms,
static fn (string $term): bool => ! $seam->documentsProviderSemantic($term),
));
if ($undocumentedTerms !== []) {
return $this->result(
status: self::STATUS_BLOCKED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_PLATFORM_CORE_PROVIDER_LEAK,
message: 'Platform-core seam contains undocumented provider-specific terms: '.implode(', ', $undocumentedTerms).'.',
suggestedFollowUp: ProviderBoundarySeam::FOLLOW_UP_SPEC,
);
}
return $this->result(
status: self::STATUS_REVIEW_REQUIRED,
seam: $seam,
filePath: $filePath,
violationCode: self::VIOLATION_NONE,
message: 'Platform-core seam relies on documented current-release provider exception metadata.',
suggestedFollowUp: $seam->followUpAction,
);
}
/**
* @param array<mixed> $terms
* @return list<string>
*/
private function normalizeTerms(array $terms): array
{
return array_values(array_filter(
array_map(static fn (mixed $term): string => trim((string) $term), $terms),
static fn (string $term): bool => $term !== '',
));
}
/**
* @return array{
* status: string,
* seam_key: string,
* file_path: string,
* violation_code: string,
* message: string,
* suggested_follow_up: string
* }
*/
private function result(
string $status,
ProviderBoundarySeam $seam,
string $filePath,
string $violationCode,
string $message,
string $suggestedFollowUp,
): array {
return [
'status' => $status,
'seam_key' => $seam->key,
'file_path' => $filePath,
'violation_code' => $violationCode,
'message' => $message,
'suggested_follow_up' => $suggestedFollowUp,
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Support\Providers\Boundary;
enum ProviderBoundaryOwner: string
{
case ProviderOwned = 'provider_owned';
case PlatformCore = 'platform_core';
/**
* @return list<string>
*/
public static function values(): array
{
return array_map(static fn (self $case): string => $case->value, self::cases());
}
}

View File

@ -0,0 +1,149 @@
<?php
namespace App\Support\Providers\Boundary;
use InvalidArgumentException;
final class ProviderBoundarySeam
{
public const string FOLLOW_UP_NONE = 'none';
public const string FOLLOW_UP_DOCUMENT_IN_FEATURE = 'document-in-feature';
public const string FOLLOW_UP_SPEC = 'follow-up-spec';
/**
* @param list<string> $implementationPaths
* @param list<string> $neutralTerms
* @param list<string> $retainedProviderSemantics
*/
public function __construct(
public readonly string $key,
public readonly ProviderBoundaryOwner $owner,
public readonly string $description,
public readonly array $implementationPaths,
public readonly array $neutralTerms,
public readonly array $retainedProviderSemantics,
public readonly string $followUpAction,
) {
$this->validate();
}
/**
* @param array{
* owner?: string,
* description?: string,
* implementation_paths?: list<string>,
* neutral_terms?: list<string>,
* retained_provider_semantics?: list<string>,
* follow_up_action?: string
* } $attributes
*/
public static function fromConfig(string $key, array $attributes): self
{
$owner = ProviderBoundaryOwner::tryFrom((string) ($attributes['owner'] ?? ''));
if (! $owner instanceof ProviderBoundaryOwner) {
throw new InvalidArgumentException("Provider boundary seam [{$key}] has an invalid owner.");
}
return new self(
key: $key,
owner: $owner,
description: (string) ($attributes['description'] ?? ''),
implementationPaths: self::stringList($attributes['implementation_paths'] ?? []),
neutralTerms: self::stringList($attributes['neutral_terms'] ?? []),
retainedProviderSemantics: self::stringList($attributes['retained_provider_semantics'] ?? []),
followUpAction: (string) ($attributes['follow_up_action'] ?? self::FOLLOW_UP_NONE),
);
}
public function isProviderOwned(): bool
{
return $this->owner === ProviderBoundaryOwner::ProviderOwned;
}
public function isPlatformCore(): bool
{
return $this->owner === ProviderBoundaryOwner::PlatformCore;
}
public function retainsProviderSemantics(): bool
{
return $this->retainedProviderSemantics !== [];
}
public function documentsProviderSemantic(string $term): bool
{
return in_array($term, $this->retainedProviderSemantics, true);
}
public function coversPath(string $path): bool
{
$normalizedPath = $this->normalizePath($path);
foreach ($this->implementationPaths as $implementationPath) {
if ($normalizedPath === $this->normalizePath($implementationPath)) {
return true;
}
}
return false;
}
/**
* @param array<mixed> $values
* @return list<string>
*/
private static function stringList(array $values): array
{
return array_values(array_filter(
array_map(static fn (mixed $value): string => trim((string) $value), $values),
static fn (string $value): bool => $value !== '',
));
}
private function validate(): void
{
if (trim($this->key) === '') {
throw new InvalidArgumentException('Provider boundary seam key cannot be empty.');
}
if (trim($this->description) === '') {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] must include a description.");
}
if ($this->implementationPaths === []) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] must include implementation paths.");
}
if ($this->isPlatformCore() && $this->neutralTerms === []) {
throw new InvalidArgumentException("Platform-core provider boundary seam [{$this->key}] must include neutral terms.");
}
if ($this->retainsProviderSemantics() && $this->followUpAction === self::FOLLOW_UP_NONE) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] retains provider semantics without a follow-up action.");
}
if (! in_array($this->followUpAction, $this->validFollowUpActions(), true)) {
throw new InvalidArgumentException("Provider boundary seam [{$this->key}] has an invalid follow-up action.");
}
}
/**
* @return list<string>
*/
private function validFollowUpActions(): array
{
return [
self::FOLLOW_UP_NONE,
self::FOLLOW_UP_DOCUMENT_IN_FEATURE,
self::FOLLOW_UP_SPEC,
];
}
private function normalizePath(string $path): string
{
return trim(str_replace('\\', '/', $path), '/');
}
}

View File

@ -34,6 +34,8 @@ final class ProviderReasonCodes
public const string ProviderConnectionReviewRequired = 'provider_connection_review_required';
public const string ProviderBindingUnsupported = 'provider_binding_unsupported';
public const string ProviderAuthFailed = 'provider_auth_failed';
public const string ProviderPermissionMissing = 'provider_permission_missing';
@ -77,6 +79,7 @@ public static function all(): array
self::ProviderConsentFailed,
self::ProviderConsentRevoked,
self::ProviderConnectionReviewRequired,
self::ProviderBindingUnsupported,
self::ProviderAuthFailed,
self::ProviderPermissionMissing,
self::ProviderPermissionDenied,
@ -139,6 +142,7 @@ public static function platformReasonFamily(string $reasonCode): PlatformReasonF
self::ProviderAuthFailed => PlatformReasonFamily::Availability,
self::ProviderConnectionTypeInvalid,
self::TenantTargetMismatch,
self::ProviderBindingUnsupported,
self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility,
default => PlatformReasonFamily::Prerequisite,
};

View File

@ -141,6 +141,13 @@ public function translate(string $reasonCode, string $surface = 'detail', array
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderBindingUnsupported => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider binding unsupported',
shortExplanation: 'This operation does not have an explicit provider binding for the selected provider.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider authentication failed',
@ -284,7 +291,8 @@ private function nextStepsFor(
ProviderReasonCodes::TenantTargetMismatch,
ProviderReasonCodes::PlatformIdentityMissing,
ProviderReasonCodes::PlatformIdentityIncomplete,
ProviderReasonCodes::ProviderConnectionReviewRequired => [
ProviderReasonCodes::ProviderConnectionReviewRequired,
ProviderReasonCodes::ProviderBindingUnsupported => [
NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection

View File

@ -0,0 +1,119 @@
<?php
namespace App\Support\Providers\TargetScope;
use App\Models\ProviderConnection;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
final class ProviderConnectionSurfaceSummary
{
/**
* @param list<ProviderIdentityContextMetadata> $contextualIdentityDetails
*/
public function __construct(
public readonly string $provider,
public readonly ProviderConnectionTargetScopeDescriptor $targetScope,
public readonly string $consentState,
public readonly string $verificationState,
public readonly string $readinessSummary,
public readonly array $contextualIdentityDetails = [],
) {}
public static function forConnection(ProviderConnection $connection): self
{
/** @var ProviderConnectionTargetScopeNormalizer $normalizer */
$normalizer = app(ProviderConnectionTargetScopeNormalizer::class);
$targetScope = $normalizer->descriptorForConnection($connection);
$consentState = self::stateValue($connection->consent_status);
$verificationState = self::stateValue($connection->verification_status);
return new self(
provider: trim((string) $connection->provider),
targetScope: $targetScope,
consentState: $consentState,
verificationState: $verificationState,
readinessSummary: self::readinessSummary(
isEnabled: (bool) $connection->is_enabled,
consentState: $consentState,
verificationState: $verificationState,
),
contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection),
);
}
public function targetScopeSummary(): string
{
return $this->targetScope->summary();
}
public function contextualIdentityLine(): ?string
{
if ($this->contextualIdentityDetails === []) {
return null;
}
return collect($this->contextualIdentityDetails)
->map(fn (ProviderIdentityContextMetadata $detail): string => sprintf('%s: %s', $detail->detailLabel, $detail->detailValue))
->implode("\n");
}
/**
* @return array{
* provider: string,
* target_scope: array<string, string>,
* consent_state: string,
* verification_state: string,
* readiness_summary: string,
* contextual_identity_details: list<array<string, string>>
* }
*/
public function toArray(): array
{
return [
'provider' => $this->provider,
'target_scope' => $this->targetScope->toArray(),
'consent_state' => $this->consentState,
'verification_state' => $this->verificationState,
'readiness_summary' => $this->readinessSummary,
'contextual_identity_details' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$this->contextualIdentityDetails,
),
];
}
private static function stateValue(mixed $state): string
{
if ($state instanceof ProviderConsentStatus || $state instanceof ProviderVerificationStatus) {
return $state->value;
}
return trim((string) $state);
}
private static function readinessSummary(bool $isEnabled, string $consentState, string $verificationState): string
{
if (! $isEnabled) {
return 'Disabled';
}
if ($consentState !== ProviderConsentStatus::Granted->value) {
return sprintf(
'Consent %s',
strtolower(BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $consentState)->label),
);
}
return match ($verificationState) {
ProviderVerificationStatus::Healthy->value => 'Ready',
ProviderVerificationStatus::Degraded->value => 'Ready with warnings',
ProviderVerificationStatus::Blocked->value => 'Verification blocked',
ProviderVerificationStatus::Error->value => 'Verification failed',
ProviderVerificationStatus::Pending->value => 'Verification pending',
default => 'Verification not run',
};
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Support\Providers\TargetScope;
use App\Models\ProviderConnection;
use InvalidArgumentException;
final class ProviderConnectionTargetScopeDescriptor
{
public const string SCOPE_KIND_TENANT = 'tenant';
public function __construct(
public readonly string $provider,
public readonly string $scopeKind,
public readonly string $scopeIdentifier,
public readonly string $scopeDisplayName,
public readonly string $sharedLabel = 'Target scope',
public readonly string $sharedHelpText = 'The platform scope this provider connection represents.',
) {
$this->validate();
}
public static function fromConnection(ProviderConnection $connection): self
{
$tenantName = is_string($connection->tenant?->name) && trim($connection->tenant->name) !== ''
? trim($connection->tenant->name)
: trim((string) $connection->display_name);
return new self(
provider: trim((string) $connection->provider),
scopeKind: self::SCOPE_KIND_TENANT,
scopeIdentifier: trim((string) $connection->entra_tenant_id),
scopeDisplayName: $tenantName !== '' ? $tenantName : trim((string) $connection->entra_tenant_id),
);
}
public static function fromInput(
string $provider,
string $scopeKind,
string $scopeIdentifier,
?string $scopeDisplayName = null,
): self {
$scopeIdentifier = trim($scopeIdentifier);
$displayName = trim((string) $scopeDisplayName);
return new self(
provider: trim($provider),
scopeKind: trim($scopeKind),
scopeIdentifier: $scopeIdentifier,
scopeDisplayName: $displayName !== '' ? $displayName : $scopeIdentifier,
);
}
public function summary(): string
{
if ($this->scopeDisplayName !== '' && $this->scopeDisplayName !== $this->scopeIdentifier) {
return sprintf('%s (%s)', $this->scopeDisplayName, $this->scopeIdentifier);
}
return $this->scopeIdentifier;
}
/**
* @return array{
* provider: string,
* scope_kind: string,
* scope_identifier: string,
* scope_display_name: string,
* shared_label: string,
* shared_help_text: string
* }
*/
public function toArray(): array
{
return [
'provider' => $this->provider,
'scope_kind' => $this->scopeKind,
'scope_identifier' => $this->scopeIdentifier,
'scope_display_name' => $this->scopeDisplayName,
'shared_label' => $this->sharedLabel,
'shared_help_text' => $this->sharedHelpText,
];
}
private function validate(): void
{
if ($this->provider === '') {
throw new InvalidArgumentException('Provider is required for target-scope descriptors.');
}
if ($this->scopeKind !== self::SCOPE_KIND_TENANT) {
throw new InvalidArgumentException('Unsupported provider connection target-scope kind.');
}
if ($this->scopeIdentifier === '') {
throw new InvalidArgumentException('Target scope identifier is required.');
}
if ($this->scopeDisplayName === '') {
throw new InvalidArgumentException('Target scope display name is required.');
}
}
}

View File

@ -0,0 +1,207 @@
<?php
namespace App\Support\Providers\TargetScope;
use App\Models\ProviderConnection;
use InvalidArgumentException;
final class ProviderConnectionTargetScopeNormalizer
{
public const string STATUS_NORMALIZED = 'normalized';
public const string STATUS_BLOCKED = 'blocked';
public const string FAILURE_NONE = 'none';
public const string FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION = 'unsupported_provider_scope_combination';
public const string FAILURE_MISSING_PROVIDER_CONTEXT = 'missing_provider_context';
/**
* @param array<string, string> $providerSpecificIdentity
* @return array{
* status: string,
* provider: string,
* scope_kind: string,
* target_scope?: ProviderConnectionTargetScopeDescriptor,
* contextual_identity_details?: list<ProviderIdentityContextMetadata>,
* preview_summary?: ?ProviderConnectionSurfaceSummary,
* failure_code: string,
* message: string
* }
*/
public function normalizeInput(
string $provider,
string $scopeKind,
string $scopeIdentifier,
?string $scopeDisplayName = null,
array $providerSpecificIdentity = [],
): array {
$provider = trim($provider);
$scopeKind = trim($scopeKind);
$scopeIdentifier = trim($scopeIdentifier);
if ($provider !== 'microsoft' || $scopeKind !== ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT) {
return $this->blocked(
provider: $provider,
scopeKind: $scopeKind,
failureCode: self::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION,
message: 'This provider and target-scope combination is not supported.',
);
}
if ($scopeIdentifier === '') {
return $this->blocked(
provider: $provider,
scopeKind: $scopeKind,
failureCode: self::FAILURE_MISSING_PROVIDER_CONTEXT,
message: 'A target scope identifier is required for this provider connection.',
);
}
$descriptor = ProviderConnectionTargetScopeDescriptor::fromInput(
provider: $provider,
scopeKind: $scopeKind,
scopeIdentifier: $scopeIdentifier,
scopeDisplayName: $scopeDisplayName,
);
return [
'status' => self::STATUS_NORMALIZED,
'provider' => $provider,
'scope_kind' => $scopeKind,
'target_scope' => $descriptor,
'contextual_identity_details' => $this->contextualIdentityDetails(
provider: $provider,
scopeIdentifier: $scopeIdentifier,
providerSpecificIdentity: $providerSpecificIdentity,
),
'preview_summary' => null,
'failure_code' => self::FAILURE_NONE,
'message' => 'Target scope normalized.',
];
}
/**
* @return array{
* status: string,
* provider: string,
* scope_kind: string,
* target_scope?: ProviderConnectionTargetScopeDescriptor,
* contextual_identity_details?: list<ProviderIdentityContextMetadata>,
* preview_summary?: ?ProviderConnectionSurfaceSummary,
* failure_code: string,
* message: string
* }
*/
public function normalizeConnection(ProviderConnection $connection): array
{
return $this->normalizeInput(
provider: trim((string) $connection->provider),
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: trim((string) $connection->entra_tenant_id),
scopeDisplayName: $connection->tenant?->name ?? $connection->display_name,
providerSpecificIdentity: [
'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id),
],
);
}
public function descriptorForConnection(ProviderConnection $connection): ProviderConnectionTargetScopeDescriptor
{
$result = $this->normalizeConnection($connection);
$descriptor = $result['target_scope'] ?? null;
if (! $descriptor instanceof ProviderConnectionTargetScopeDescriptor) {
throw new InvalidArgumentException($result['message']);
}
return $descriptor;
}
/**
* @return list<ProviderIdentityContextMetadata>
*/
public function contextualIdentityDetailsForConnection(ProviderConnection $connection): array
{
return $this->contextualIdentityDetails(
provider: trim((string) $connection->provider),
scopeIdentifier: trim((string) $connection->entra_tenant_id),
providerSpecificIdentity: [
'microsoft_tenant_id' => trim((string) $connection->entra_tenant_id),
],
);
}
/**
* @return array<string, mixed>
*/
public function auditMetadataForConnection(ProviderConnection $connection, array $extra = []): array
{
$summary = ProviderConnectionSurfaceSummary::forConnection($connection);
return array_merge([
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'target_scope' => $summary->targetScope->toArray(),
'provider_identity_context' => array_map(
static fn (ProviderIdentityContextMetadata $detail): array => $detail->toArray(),
$summary->contextualIdentityDetails,
),
], $extra);
}
/**
* @param list<string> $fields
* @return list<string>
*/
public function auditFieldNames(array $fields): array
{
return array_values(array_map(
static fn (string $field): string => $field === 'entra_tenant_id' ? 'target_scope_identifier' : $field,
$fields,
));
}
/**
* @param array<string, string> $providerSpecificIdentity
* @return list<ProviderIdentityContextMetadata>
*/
private function contextualIdentityDetails(string $provider, string $scopeIdentifier, array $providerSpecificIdentity = []): array
{
if ($provider !== 'microsoft') {
return [];
}
$details = [
ProviderIdentityContextMetadata::microsoftTenantId(
$providerSpecificIdentity['microsoft_tenant_id'] ?? $scopeIdentifier,
),
];
return array_values(array_filter(
$details,
static fn (?ProviderIdentityContextMetadata $detail): bool => $detail instanceof ProviderIdentityContextMetadata,
));
}
/**
* @return array{
* status: string,
* provider: string,
* scope_kind: string,
* failure_code: string,
* message: string
* }
*/
private function blocked(string $provider, string $scopeKind, string $failureCode, string $message): array
{
return [
'status' => self::STATUS_BLOCKED,
'provider' => $provider,
'scope_kind' => $scopeKind,
'failure_code' => $failureCode,
'message' => $message,
];
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Support\Providers\TargetScope;
final class ProviderIdentityContextMetadata
{
public const string VISIBILITY_CONTEXTUAL_ONLY = 'contextual_only';
public const string VISIBILITY_AUDIT_ONLY = 'audit_only';
public const string VISIBILITY_TROUBLESHOOTING_ONLY = 'troubleshooting_only';
public function __construct(
public readonly string $provider,
public readonly string $detailKey,
public readonly string $detailLabel,
public readonly string $detailValue,
public readonly string $visibility = self::VISIBILITY_CONTEXTUAL_ONLY,
) {}
public static function microsoftTenantId(?string $value, string $visibility = self::VISIBILITY_CONTEXTUAL_ONLY): ?self
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
return new self(
provider: 'microsoft',
detailKey: 'microsoft_tenant_id',
detailLabel: 'Microsoft tenant ID',
detailValue: $value,
visibility: $visibility,
);
}
public static function authorityTenant(?string $value, string $visibility = self::VISIBILITY_TROUBLESHOOTING_ONLY): ?self
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
return new self(
provider: 'microsoft',
detailKey: 'authority_tenant',
detailLabel: 'Authority tenant',
detailValue: $value,
visibility: $visibility,
);
}
public static function redirectUri(?string $value, string $visibility = self::VISIBILITY_TROUBLESHOOTING_ONLY): ?self
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
return new self(
provider: 'microsoft',
detailKey: 'redirect_uri',
detailLabel: 'Redirect URI',
detailValue: $value,
visibility: $visibility,
);
}
/**
* @return array{
* provider: string,
* detail_key: string,
* detail_label: string,
* detail_value: string,
* visibility: string
* }
*/
public function toArray(): array
{
return [
'provider' => $this->provider,
'detail_key' => $this->detailKey,
'detail_label' => $this->detailLabel,
'detail_value' => $this->detailValue,
'visibility' => $this->visibility,
];
}
}

View File

@ -0,0 +1,115 @@
<?php
use App\Support\Providers\Boundary\ProviderBoundaryOwner;
use App\Support\Providers\Boundary\ProviderBoundarySeam;
return [
'seams' => [
'provider.gateway_runtime' => [
'owner' => ProviderBoundaryOwner::ProviderOwned->value,
'description' => 'Provider-owned runtime boundary that translates provider connection identity into Microsoft Graph request options and executes Graph calls.',
'implementation_paths' => [
'app/Services/Providers/ProviderGateway.php',
'app/Services/Providers/MicrosoftGraphOptionsResolver.php',
],
'neutral_terms' => [
'provider',
'provider connection',
'target scope',
'runtime request context',
],
'retained_provider_semantics' => [
'Microsoft Graph option keys',
'client_request_id',
'tenant',
'client_id',
'client_secret',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.identity_resolution' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core identity resolution contract that resolves provider connection identity without owning provider transport option shaping.',
'implementation_paths' => [
'app/Services/Providers/ProviderIdentityResolution.php',
'app/Services/Providers/ProviderIdentityResolver.php',
'app/Services/Providers/PlatformProviderIdentityResolver.php',
],
'neutral_terms' => [
'provider connection',
'target scope',
'credential source',
'effective client identity',
],
'retained_provider_semantics' => [
'entra_tenant_id',
'platform_config',
'graph.tenant_id',
'admin.consent.callback',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
],
'provider.connection_resolution' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core provider connection selection and validation path that keeps current Microsoft connection details as bounded exception metadata.',
'implementation_paths' => [
'app/Services/Providers/ProviderConnectionResolver.php',
'app/Services/Providers/ProviderConnectionResolution.php',
],
'neutral_terms' => [
'provider',
'provider connection',
'tenant scope',
'default binding',
'unsupported combination',
],
'retained_provider_semantics' => [
'microsoft',
'entra_tenant_id',
'consent_status',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
],
'provider.operation_registry' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core operation definition catalog with provider binding metadata kept explicit and secondary.',
'implementation_paths' => [
'app/Services/Providers/ProviderOperationRegistry.php',
],
'neutral_terms' => [
'operation type',
'operation module',
'required capability',
'provider binding',
'unsupported binding',
],
'retained_provider_semantics' => [
'microsoft',
'active provider binding',
'binding_status',
'handler_notes',
'exception_notes',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
],
'provider.operation_start_gate' => [
'owner' => ProviderBoundaryOwner::PlatformCore->value,
'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records current Microsoft target-scope exceptions.',
'implementation_paths' => [
'app/Services/Providers/ProviderOperationStartGate.php',
],
'neutral_terms' => [
'operation',
'provider binding',
'target scope',
'execution authority',
'required capability',
],
'retained_provider_semantics' => [
'microsoft',
'target_scope.entra_tenant_id',
],
'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_SPEC,
],
],
];

View File

@ -2,10 +2,14 @@
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
use App\Models\AuditLog;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@ -63,3 +67,55 @@
]);
}
});
it('records provider connection create audits with neutral target-scope metadata', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(CreateProviderConnection::class)
->fillForm([
'display_name' => 'Audit target scope connection',
'entra_tenant_id' => '88888888-8888-8888-8888-888888888888',
'is_default' => true,
])
->call('create')
->assertHasNoFormErrors();
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('display_name', 'Audit target scope connection')
->firstOrFail();
$log = AuditLog::query()
->where('tenant_id', (int) $tenant->getKey())
->where('action', 'provider_connection.created')
->where('resource_id', (string) $connection->getKey())
->firstOrFail();
$metadata = is_array($log->metadata) ? $log->metadata : [];
expect($metadata)->toHaveKeys([
'provider_connection_id',
'provider',
'target_scope',
'provider_identity_context',
'connection_type',
])
->and($metadata)->not->toHaveKey('entra_tenant_id')
->and($metadata['target_scope'])->toMatchArray([
'provider' => 'microsoft',
'scope_kind' => 'tenant',
'scope_identifier' => '88888888-8888-8888-8888-888888888888',
'shared_label' => 'Target scope',
])
->and($metadata['provider_identity_context'][0] ?? [])->toMatchArray([
'provider' => 'microsoft',
'detail_key' => 'microsoft_tenant_id',
'detail_label' => 'Microsoft tenant ID',
'detail_value' => '88888888-8888-8888-8888-888888888888',
]);
});

View File

@ -58,15 +58,18 @@
->all();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($visibleColumnNames)->toContain('is_enabled', 'consent_status', 'verification_status');
expect($visibleColumnNames)->toContain('provider', 'target_scope', 'is_enabled', 'consent_status', 'verification_status');
expect($visibleColumnNames)->not->toContain('status');
expect($visibleColumnNames)->not->toContain('health_status');
expect($visibleColumnNames)->not->toContain('entra_tenant_id');
expect($table->getColumn('status'))->toBeNull();
expect($table->getColumn('health_status'))->toBeNull();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse();
expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope');
expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('migration_review_required'))->not->toBeNull();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);

View File

@ -35,6 +35,24 @@
->assertDontSee('Unauthorized Tenant Connection');
});
test('non-members cannot reach provider connection detail target-scope metadata', function (): void {
$tenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
[$user] = createUserWithTenant($otherTenant, role: 'owner');
$connection = ProviderConnection::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => 'Hidden Scope Connection',
'entra_tenant_id' => '77777777-7777-7777-7777-777777777777',
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant))
->assertNotFound();
});
test('members without capability see provider connection actions disabled with standard tooltip', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'readonly');
@ -99,3 +117,26 @@
->assertActionVisible('edit')
->assertActionEnabled('edit');
});
test('sensitive provider connection mutations remain confirmation and capability gated', function (): void {
$source = (string) file_get_contents(repo_path('apps/platform/app/Filament/Resources/ProviderConnectionResource.php'));
foreach ([
'makeSetDefaultAction',
'makeEnableDedicatedOverrideAction',
'makeRotateDedicatedCredentialAction',
'makeDeleteDedicatedCredentialAction',
'makeRevertToPlatformAction',
'makeEnableConnectionAction',
'makeDisableConnectionAction',
] as $method) {
$start = strpos($source, 'public static function '.$method);
expect($start)->not->toBeFalse();
$next = strpos($source, "\n public static function ", $start + 1);
$block = substr($source, $start, $next === false ? null : $next - $start);
expect($block)->toContain('->requiresConfirmation()')
->and($block)->toContain('->requireCapability(');
}
});

View File

@ -166,19 +166,21 @@ function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnec
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed Microsoft connection. Dedicated overrides are handled separately with stronger authorization.');
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
expect($table->getEmptyStateDescription())->toBe('Start with a platform-managed provider connection. Dedicated overrides are handled separately with stronger authorization.');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('provider')?->isToggleable())->toBeTrue();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('provider')?->isToggleable())->toBeFalse();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeFalse();
expect($table->getColumn('target_scope')?->getLabel())->toBe('Target scope');
expect($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
expect($table->getColumn('entra_tenant_id')?->isToggleable())->toBeTrue();
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('last_error_reason_code')?->isToggleable())->toBeTrue();
expect($table->getColumn('last_error_reason_code')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('last_error_message')?->isToggleable())->toBeTrue();
expect($table->getColumn('last_error_message')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(8);
});
it('standardizes the findings list around open triage work with hidden forensic detail', function (): void {

View File

@ -0,0 +1,47 @@
<?php
use App\Services\Providers\ProviderOperationRegistry;
use App\Support\Providers\Boundary\ProviderBoundaryCatalog;
it('blocks graph request option helpers from platform-core identity resolution seams', function (): void {
$root = base_path();
$catalog = app(ProviderBoundaryCatalog::class);
$seam = $catalog->get('provider.identity_resolution');
$hits = [];
foreach ($seam->implementationPaths as $relativePath) {
if ($relativePath !== 'app/Services/Providers/ProviderIdentityResolution.php') {
continue;
}
$path = $root.'/'.$relativePath;
$contents = file_get_contents($path);
if (! is_string($contents)) {
continue;
}
foreach (['graphOptions', 'client_request_id'] as $forbiddenTerm) {
if (str_contains($contents, $forbiddenTerm)) {
$hits[] = $relativePath.' contains '.$forbiddenTerm;
}
}
}
expect($hits)->toBeEmpty('Platform-core identity resolution must not shape Microsoft Graph request options.');
});
it('keeps provider bindings out of platform-core operation definitions', function (): void {
$registry = app(ProviderOperationRegistry::class);
foreach ($registry->definitions() as $operationType => $definition) {
expect($definition)
->not->toHaveKey('provider')
->not->toHaveKey('binding_status')
->not->toHaveKey('handler_notes')
->not->toHaveKey('exception_notes');
expect($registry->bindingFor($operationType, 'microsoft'))->toBeArray();
}
});

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
it('blocks Microsoft-specific default labels and audit prose on shared provider connection surfaces', function (): void {
$root = repo_path('apps/platform');
$paths = [
'app/Filament/Resources/ProviderConnectionResource.php',
'app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php',
'app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php',
'app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php',
'app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
'app/Services/Providers/ProviderConnectionMutationService.php',
'app/Services/Verification/StartVerification.php',
'app/Jobs/ProviderConnectionHealthCheckJob.php',
];
$forbiddenFragments = [
'Entra tenant ID',
'Entra Tenant ID',
'Directory (tenant) ID',
'No Microsoft connections found',
'Graph API calls',
"'entra_tenant_id' => \$record->entra_tenant_id",
"'entra_tenant_id' => (string) \$connection->entra_tenant_id",
];
foreach ($paths as $path) {
$contents = (string) file_get_contents($root.'/'.$path);
foreach ($forbiddenFragments as $fragment) {
expect($contents)
->not->toContain($fragment, sprintf('%s still contains shared-surface provider-specific default prose [%s].', $path, $fragment));
}
}
});

View File

@ -99,7 +99,7 @@
]);
});
it('renders the Entra tenant id placeholder for onboarding input guidance', function (): void {
it('renders neutral tenant id placeholder guidance for onboarding input', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
@ -114,9 +114,39 @@
$this->actingAs($user)
->get('/admin/onboarding')
->assertSuccessful()
->assertSee('Tenant ID (GUID)')
->assertSee('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', false);
});
it('uses target-scope wording in the onboarding provider setup step', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$entraTenantId = '34343434-3434-3434-3434-343434343434';
Livewire::actingAs($user)
->test(ManagedTenantOnboardingWizard::class)
->call('identifyManagedTenant', [
'entra_tenant_id' => $entraTenantId,
'environment' => 'prod',
'name' => 'Target Scope Tenant',
])
->set('data.connection_mode', 'new')
->assertSee('Target scope ID')
->assertSee('The provider connection will point to this tenant target scope.')
->assertSee($entraTenantId)
->assertDontSee('Directory (tenant) ID')
->assertDontSee('Graph API calls');
});
it('renders review summary guidance and activation consequences for ready onboarding sessions', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
@ -195,7 +225,7 @@
->assertSuccessful()
->assertSee('Skipped - No bootstrap actions selected')
->assertSee('Tenant status will be set to Active.')
->assertSee('The provider connection will be used for all Graph API calls.');
->assertSee('The provider connection will be used for provider API calls.');
});
it('renders selected bootstrap actions in the review summary before any bootstrap run starts', function (): void {

View File

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\CreateProviderConnection;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Models\ProviderConnection;
use App\Services\Providers\ProviderConnectionResolver;
use Filament\Facades\Filament;
use Livewire\Livewire;
it('renders create and edit flows with neutral target-scope labels', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Neutral connection',
'entra_tenant_id' => '44444444-4444-4444-4444-444444444444',
]);
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('create', ['tenant_id' => $tenant->external_id], panel: 'admin'))
->assertOk()
->assertSee('Target scope ID')
->assertSee('Target scope')
->assertDontSee('Entra tenant ID');
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin'))
->assertOk()
->assertSee('Target scope ID')
->assertSee('Target scope')
->assertDontSee('Entra tenant ID');
});
it('keeps list and detail surfaces default-visible around provider target scope consent and verification', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Scope-visible connection',
'entra_tenant_id' => '55555555-5555-5555-5555-555555555555',
'consent_status' => 'granted',
'verification_status' => 'healthy',
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(ListProviderConnections::class);
$table = $component->instance()->getTable();
$visibleColumnNames = collect($table->getVisibleColumns())
->map(fn ($column): string => $column->getName())
->values()
->all();
expect($visibleColumnNames)->toContain('provider', 'target_scope', 'consent_status', 'verification_status')
->and($visibleColumnNames)->not->toContain('entra_tenant_id')
->and($table->getColumn('target_scope')?->getLabel())->toBe('Target scope')
->and($table->getColumn('entra_tenant_id')?->getLabel())->toBe('Microsoft tenant ID');
$this->actingAs($user)
->get(ProviderConnectionResource::getUrl('view', ['record' => $connection, 'tenant_id' => $tenant->external_id], panel: 'admin'))
->assertOk()
->assertSee('Target scope')
->assertSee('Provider identity details')
->assertSee('Microsoft tenant ID')
->assertSee('Consent')
->assertSee('Verification')
->assertDontSee('Entra tenant ID');
});
it('uses neutral validation attributes when the create flow misses target-scope context', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(CreateProviderConnection::class)
->fillForm([
'display_name' => 'Missing target scope',
'entra_tenant_id' => '',
'is_default' => true,
])
->call('create')
->assertHasFormErrors(['entra_tenant_id' => 'required']);
});
it('blocks unsupported provider target-scope combinations before provider execution', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->consentGranted()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'contoso',
'display_name' => 'Unsupported provider connection',
'entra_tenant_id' => '66666666-6666-6666-6666-666666666666',
'is_enabled' => true,
]);
$resolution = app(ProviderConnectionResolver::class)
->validateConnection($tenant, 'contoso', $connection->fresh(['tenant']));
expect($user)->not->toBeNull()
->and($resolution->resolved)->toBeFalse()
->and($resolution->reasonCode)->toBe('provider_binding_unsupported')
->and($resolution->extensionReasonCode)->toBe('ext.connection_scope_unsupported')
->and($resolution->message)->toBe('This provider and target-scope combination is not supported.');
});

View File

@ -38,11 +38,14 @@
$this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
->assertOk()
->assertSee('Spec081 Connection')
->assertSee('Target scope')
->assertSee('Target scope ID')
->assertSee('Lifecycle')
->assertSee('Enabled')
->assertSee('Verification')
->assertSee('Migration review')
->assertSee('Review required')
->assertDontSee('Entra tenant ID')
->assertDontSee('Diagnostic status')
->assertDontSee('Diagnostic health');
});

View File

@ -0,0 +1,109 @@
<?php
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderGateway;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('preserves current Microsoft-backed gateway runtime behavior through the provider-owned seam', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => 'entra-tenant-id',
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$graph = new class implements GraphClientInterface
{
/** @var array<string, mixed> */
public array $options = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
$this->options = $options;
return new GraphResponse(true);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true);
}
};
$gateway = app()->make(ProviderGateway::class, [
'graph' => $graph,
]);
$gateway->listPolicies($connection, 'deviceConfiguration', ['query' => ['$select' => 'id']]);
expect($graph->options)->toMatchArray([
'tenant' => 'entra-tenant-id',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'query' => ['$select' => 'id'],
]);
expect($graph->options['client_request_id'])->toBeString()->not->toBeEmpty();
});
it('preserves Microsoft graph option resolution for default provider connections', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => 'default-entra-tenant-id',
'is_default' => true,
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'default-client-id',
'client_secret' => 'default-client-secret',
],
]);
$options = app(MicrosoftGraphOptionsResolver::class)->resolveForTenant($tenant);
expect($options['tenant'])->toBe('default-entra-tenant-id')
->and($options['client_id'])->toBe('default-client-id')
->and($options['client_secret'])->toBe('default-client-secret')
->and($options['client_request_id'])->toBeString()->not->toBeEmpty();
});

View File

@ -0,0 +1,45 @@
<?php
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use App\Services\Providers\ProviderConfigurationRequiredException;
use App\Services\Providers\ProviderGateway;
use App\Support\Providers\ProviderReasonCodes;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('fails explicitly when provider-owned graph option assembly cannot resolve identity', function (): void {
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'entra_tenant_id' => 'entra-tenant-id',
]);
$call = fn (): array => app(ProviderGateway::class)->graphOptions($connection);
expect($call)->toThrow(RuntimeException::class, 'Provider credentials are missing.');
});
it('does not inherit Microsoft runtime behavior when a default provider connection is unsupported', function (): void {
$tenant = Tenant::factory()->create();
ProviderConnection::factory()->dedicated()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
'provider' => 'microsoft',
'entra_tenant_id' => 'entra-tenant-id',
'is_default' => true,
'consent_status' => 'required',
]);
$call = fn (): array => app(MicrosoftGraphOptionsResolver::class)->resolveForTenant($tenant);
expect($call)->toThrow(ProviderConfigurationRequiredException::class);
try {
$call();
} catch (ProviderConfigurationRequiredException $exception) {
expect($exception->reasonCode)->toBe(ProviderReasonCodes::ProviderConsentMissing)
->and($exception->provider)->toBe('microsoft');
}
});

View File

@ -45,6 +45,13 @@
expect($degraded->label)->toBe('Degraded');
});
it('does not reuse consent labels for provider verification summaries', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label)->toBe('Required')
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label)->toBe('Pending')
->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'required')->label)
->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'pending')->label);
});
it('does not expose legacy provider status badge domains anymore', function (): void {
$domainValues = collect(BadgeDomain::cases())
->map(fn (BadgeDomain $domain): string => $domain->value)

View File

@ -0,0 +1,57 @@
<?php
use App\Support\Providers\Boundary\ProviderBoundaryCatalog;
use App\Support\Providers\Boundary\ProviderBoundaryOwner;
use App\Support\Providers\Boundary\ProviderBoundarySeam;
it('classifies the authoritative first-slice provider seams', function (): void {
$catalog = app(ProviderBoundaryCatalog::class);
expect(array_keys($catalog->all()))->toBe([
'provider.connection_resolution',
'provider.gateway_runtime',
'provider.identity_resolution',
'provider.operation_registry',
'provider.operation_start_gate',
]);
expect($catalog->get('provider.gateway_runtime')->owner)->toBe(ProviderBoundaryOwner::ProviderOwned);
expect($catalog->get('provider.identity_resolution')->owner)->toBe(ProviderBoundaryOwner::PlatformCore);
expect($catalog->get('provider.connection_resolution')->owner)->toBe(ProviderBoundaryOwner::PlatformCore);
expect($catalog->get('provider.operation_registry')->owner)->toBe(ProviderBoundaryOwner::PlatformCore);
expect($catalog->get('provider.operation_start_gate')->owner)->toBe(ProviderBoundaryOwner::PlatformCore);
});
it('records implementation paths and bounded exception metadata for platform-core seams', function (): void {
$catalog = app(ProviderBoundaryCatalog::class);
$identity = $catalog->get('provider.identity_resolution');
expect($identity->coversPath('app/Services/Providers/ProviderIdentityResolution.php'))->toBeTrue()
->and($identity->neutralTerms)->toContain('target scope')
->and($identity->retainedProviderSemantics)->toContain('entra_tenant_id')
->and($identity->retainedProviderSemantics)->not->toContain('Microsoft Graph option keys')
->and($identity->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_SPEC);
$registry = $catalog->get('provider.operation_registry');
expect($registry->neutralTerms)->toContain('provider binding')
->and($registry->retainedProviderSemantics)->toContain('active provider binding')
->and($registry->followUpAction)->toBe(ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE);
});
it('aligns the catalog shape with the logical boundary contract', function (): void {
$seam = app(ProviderBoundaryCatalog::class)->get('provider.operation_start_gate');
expect($seam->key)->toBe('provider.operation_start_gate')
->and($seam->owner->value)->toBeIn(ProviderBoundaryOwner::values())
->and($seam->description)->not->toBeEmpty()
->and($seam->implementationPaths)->not->toBeEmpty()
->and($seam->neutralTerms)->not->toBeEmpty()
->and($seam->retainedProviderSemantics)->toContain('target_scope.entra_tenant_id')
->and($seam->followUpAction)->toBeIn([
ProviderBoundarySeam::FOLLOW_UP_NONE,
ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE,
ProviderBoundarySeam::FOLLOW_UP_SPEC,
]);
});

View File

@ -0,0 +1,75 @@
<?php
use App\Services\Providers\ProviderOperationRegistry;
use App\Support\Providers\Boundary\ProviderBoundaryCatalog;
use App\Support\Providers\Boundary\ProviderBoundaryOwner;
it('blocks undocumented provider terms in platform-core seams', function (): void {
$result = app(ProviderBoundaryCatalog::class)->evaluateChange(
seamKey: 'provider.identity_resolution',
filePath: 'app/Services/Providers/ProviderIdentityResolution.php',
proposedOwner: ProviderBoundaryOwner::PlatformCore,
providerSpecificTerms: ['client_request_id'],
);
expect($result['status'])->toBe(ProviderBoundaryCatalog::STATUS_BLOCKED)
->and($result['violation_code'])->toBe(ProviderBoundaryCatalog::VIOLATION_PLATFORM_CORE_PROVIDER_LEAK)
->and($result['suggested_follow_up'])->toBe('follow-up-spec');
});
it('requires review for documented current-release exceptions on platform-core seams', function (): void {
$result = app(ProviderBoundaryCatalog::class)->evaluateChange(
seamKey: 'provider.identity_resolution',
filePath: 'app/Services/Providers/ProviderIdentityResolver.php',
proposedOwner: 'platform_core',
providerSpecificTerms: ['entra_tenant_id'],
);
expect($result['status'])->toBe(ProviderBoundaryCatalog::STATUS_REVIEW_REQUIRED)
->and($result['violation_code'])->toBe(ProviderBoundaryCatalog::VIOLATION_NONE)
->and($result['suggested_follow_up'])->toBe('follow-up-spec');
});
it('allows provider-specific terms inside provider-owned seams', function (): void {
$result = app(ProviderBoundaryCatalog::class)->evaluateChange(
seamKey: 'provider.gateway_runtime',
filePath: 'app/Services/Providers/ProviderGateway.php',
proposedOwner: ProviderBoundaryOwner::ProviderOwned,
providerSpecificTerms: ['client_request_id', 'client_secret'],
);
expect($result['status'])->toBe(ProviderBoundaryCatalog::STATUS_ALLOWED)
->and($result['violation_code'])->toBe(ProviderBoundaryCatalog::VIOLATION_NONE);
});
it('keeps operation definitions separate from provider bindings', function (): void {
$registry = app(ProviderOperationRegistry::class);
$definition = $registry->get('provider.connection.check');
$binding = $registry->bindingFor('provider.connection.check', 'microsoft');
expect($definition)->toMatchArray([
'operation_type' => 'provider.connection.check',
'module' => 'health_check',
'label' => 'Provider connection check',
'required_capability' => \App\Support\Auth\Capabilities::PROVIDER_RUN,
]);
expect($binding)->toMatchArray([
'provider' => 'microsoft',
'binding_status' => ProviderOperationRegistry::BINDING_ACTIVE,
]);
});
it('blocks provider binding metadata when it is proposed as platform-core truth', function (): void {
$result = app(ProviderBoundaryCatalog::class)->evaluateChange(
seamKey: 'provider.operation_registry',
filePath: 'app/Services/Providers/ProviderOperationRegistry.php',
proposedOwner: ProviderBoundaryOwner::PlatformCore,
providerSpecificTerms: ['microsoft'],
introducesNewBinding: true,
);
expect($result['status'])->toBe(ProviderBoundaryCatalog::STATUS_BLOCKED)
->and($result['violation_code'])->toBe(ProviderBoundaryCatalog::VIOLATION_PROVIDER_BINDING_AS_PRIMARY_TRUTH);
});

View File

@ -18,6 +18,13 @@
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked')->label)->toBe('Blocked');
});
it('keeps consent and verification badge domains distinct for provider connection summaries', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label)->toBe('Granted')
->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label)->toBe('Healthy')
->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'granted')->label)
->not->toBe(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'healthy')->label);
});
it('maps managed-tenant onboarding verification badge aliases consistently', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'unknown')->label)->toBe('Not started')
->and(BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, 'healthy')->label)->toBe('Ready')

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeNormalizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('normalizes provider connections into neutral target-scope descriptors with contextual Microsoft metadata', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
'display_name' => 'Primary connection',
]);
$descriptor = app(ProviderConnectionTargetScopeNormalizer::class)
->descriptorForConnection($connection->fresh(['tenant']));
$summary = ProviderConnectionSurfaceSummary::forConnection($connection->fresh(['tenant']));
expect($user)->not->toBeNull()
->and($descriptor->provider)->toBe('microsoft')
->and($descriptor->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
->and($descriptor->scopeIdentifier)->toBe('11111111-1111-1111-1111-111111111111')
->and($descriptor->sharedLabel)->toBe('Target scope')
->and($descriptor->summary())->toContain((string) $tenant->name)
->and($summary->targetScopeSummary())->toContain('11111111-1111-1111-1111-111111111111')
->and($summary->contextualIdentityDetails)->toHaveCount(1)
->and($summary->contextualIdentityDetails[0]->detailLabel)->toBe('Microsoft tenant ID');
});
it('blocks unsupported provider-scope combinations explicitly instead of inheriting Microsoft defaults', function (): void {
$result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'unknown-provider',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: 'scope-1',
scopeDisplayName: 'Scope 1',
);
expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED)
->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_UNSUPPORTED_PROVIDER_SCOPE_COMBINATION)
->and($result['message'])->toContain('not supported');
});
it('blocks missing target-scope context with neutral validation language', function (): void {
$result = app(ProviderConnectionTargetScopeNormalizer::class)->normalizeInput(
provider: 'microsoft',
scopeKind: ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT,
scopeIdentifier: '',
scopeDisplayName: 'Missing scope',
);
expect($result['status'])->toBe(ProviderConnectionTargetScopeNormalizer::STATUS_BLOCKED)
->and($result['failure_code'])->toBe(ProviderConnectionTargetScopeNormalizer::FAILURE_MISSING_PROVIDER_CONTEXT)
->and($result['message'])->toBe('A target scope identifier is required for this provider connection.');
});

View File

@ -183,3 +183,31 @@ public function request(string $method, string $path, array $options = []): Grap
->and($graph->lastOptions['client_id'] ?? null)->not->toBe('dedicated-fallback-client-id')
->and($graph->lastOptions['client_request_id'] ?? null)->toBeString()->not->toBeEmpty();
});
it('owns graph request option assembly for resolved provider identities', function (): void {
$connection = ProviderConnection::factory()->dedicated()->create([
'entra_tenant_id' => 'entra-tenant-id',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => $connection->getKey(),
'payload' => [
'client_id' => 'client-id',
'client_secret' => 'client-secret',
],
]);
$gateway = app(ProviderGateway::class);
$options = $gateway->graphOptions($connection, [
'query' => ['$select' => 'id'],
]);
expect($options)->toMatchArray([
'tenant' => 'entra-tenant-id',
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'query' => ['$select' => 'id'],
]);
expect($options['client_request_id'])->toBeString()->not->toBeEmpty();
});

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Services\Providers\ProviderIdentityResolver;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('exposes neutral target-scope truth beside provider-owned identity metadata', function (): void {
config()->set('graph.client_id', 'platform-client-id');
config()->set('graph.client_secret', 'platform-client-secret');
config()->set('graph.tenant_id', 'platform-home-tenant-id');
$tenant = Tenant::factory()->create([
'tenant_id' => '22222222-2222-2222-2222-222222222222',
]);
$connection = ProviderConnection::factory()->platform()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => '22222222-2222-2222-2222-222222222222',
]);
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant']));
expect($resolution->resolved)->toBeTrue()
->and($resolution->connectionType)->toBe(ProviderConnectionType::Platform)
->and($resolution->tenantContext)->toBe('22222222-2222-2222-2222-222222222222')
->and($resolution->targetScope)->not->toBeNull()
->and($resolution->targetScope?->scopeKind)->toBe(ProviderConnectionTargetScopeDescriptor::SCOPE_KIND_TENANT)
->and($resolution->targetScope?->scopeIdentifier)->toBe('22222222-2222-2222-2222-222222222222')
->and(collect($resolution->contextualIdentityDetails)->pluck('detailKey')->all())
->toContain('microsoft_tenant_id', 'authority_tenant', 'redirect_uri');
});
it('keeps dedicated runtime credentials out of the shared target-scope descriptor', function (): void {
$connection = ProviderConnection::factory()->dedicated()->create([
'entra_tenant_id' => '33333333-3333-3333-3333-333333333333',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'dedicated-client-id',
'client_secret' => 'dedicated-client-secret',
],
]);
$resolution = app(ProviderIdentityResolver::class)->resolve($connection->fresh(['tenant', 'credential']));
expect($resolution->resolved)->toBeTrue()
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_id')
->and($resolution->targetScope?->toArray())->not->toHaveKey('client_secret')
->and($resolution->effectiveClientId)->toBe('dedicated-client-id');
});

View File

@ -72,6 +72,27 @@
->and($resolution->credentialSource)->toBe('dedicated_manual');
});
it('keeps graph request option shaping out of identity resolution results', function (): void {
$connection = ProviderConnection::factory()->dedicated()->create([
'entra_tenant_id' => 'dedicated-target-tenant-id',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
'payload' => [
'client_id' => 'dedicated-client-id',
'client_secret' => 'dedicated-client-secret',
],
]);
$resolution = app(ProviderIdentityResolver::class)->resolve($connection);
expect($resolution->resolved)->toBeTrue()
->and($resolution->tenantContext)->toBe('dedicated-target-tenant-id')
->and($resolution->effectiveClientId)->toBe('dedicated-client-id')
->and(method_exists($resolution, 'graphOptions'))->toBeFalse();
});
it('blocks dedicated connections when dedicated credentials are missing', function (): void {
$connection = ProviderConnection::factory()->dedicated()->create([
'entra_tenant_id' => 'dedicated-target-tenant-id',

View File

@ -57,6 +57,8 @@
'entra_tenant_id' => 'entra-tenant-id',
],
]);
expect($run->context['provider_binding']['provider'] ?? null)->toBe('microsoft')
->and($run->context['provider_binding']['binding_status'] ?? null)->toBe('active');
});
it('dedupes when the same operation is already active for the scope', function (): void {
@ -278,3 +280,39 @@
expect($result->status)->toBe('scope_busy');
expect($result->run->getKey())->toBe($blocking->getKey());
});
it('blocks provider starts when no explicit provider binding supports the connection provider', function (): void {
$tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'contoso',
'entra_tenant_id' => 'contoso-tenant-id',
'consent_status' => 'granted',
]);
ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(),
]);
$dispatched = 0;
$gate = app(ProviderOperationStartGate::class);
$result = $gate->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function () use (&$dispatched): void {
$dispatched++;
},
);
expect($dispatched)->toBe(0);
expect($result->status)->toBe('blocked');
expect($result->run->context)->toMatchArray([
'provider' => 'contoso',
'module' => 'health_check',
'reason_code' => ProviderReasonCodes::ProviderBindingUnsupported,
'reason_code_extension' => 'ext.provider_binding_missing',
]);
expect($result->run->context['provider_binding']['provider'] ?? null)->toBe('contoso')
->and($result->run->context['provider_binding']['binding_status'] ?? null)->toBe('unsupported');
});

View File

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs.
**Last updated**: 2026-04-24
**Last updated**: 2026-04-25
---
@ -25,7 +25,7 @@ ### Governance & Architecture Hardening
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands, while preventing the Governance-of-Record platform core from drifting into provider-specific or operation-type dual semantics.
**Active specs**: 144
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate). Next foundation candidates: Canonical Operation Type Source of Truth, Provider Boundary Hardening, Provider Identity & Target Scope Neutrality, Platform Vocabulary Boundary Enforcement for Governed Subject Keys.
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate), 237 (provider boundary hardening). Next foundation candidates: Canonical Operation Type Source of Truth, Provider Identity & Target Scope Neutrality, Platform Vocabulary Boundary Enforcement for Governed Subject Keys.
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane.
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
@ -90,6 +90,7 @@ ### R1.x Foundation Hardening — Governance Platform Anti-Drift
- Provider Boundary Hardening so provider-specific behavior stays inside provider adapters and registries
- Provider Identity & Target Scope Neutrality so Entra-specific identifiers do not become generic platform truth
- Platform Vocabulary Boundary Enforcement for Governed Subject Keys so `policy_type` and similar provider/domain terms do not leak into the platform core
- Codebase Quality & Engineering Maturity hardening so the platform remains enterprise-maintainable while the governance surface grows: System Panel least-privilege capabilities, static-analysis baseline, architecture-boundary guard tests, and targeted decomposition of large Filament/service hotspots
- No AWS/GCP/SaaS connector implementation in this slice; this is anti-drift foundation work only
### R2 Completion — Evidence & Exception Workflows
@ -226,8 +227,9 @@ ## Infrastructure & Platform Debt
| Item | Risk | Status |
|------|------|--------|
| No `.env.example` in repo | Onboarding friction | Open |
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited | Review needed |
| No PHPStan/Larastan | No static analysis | Open |
| CI pipeline config status drift | Roadmap debt list may be stale because workflow files exist and should be re-audited; align with static-analysis and architecture-boundary gates | Review needed |
| No PHPStan/Larastan | No static analysis; covered by `Static Analysis Baseline for Platform Code` spec candidate | Open |
| Thin architecture-boundary enforcement | Product tests are strong, but architecture-level guardrails need expansion; covered by `Architecture Boundary Guard Tests` spec candidate | Open |
| SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open |
| No formal release process | Manual deploys | Open |
| Dokploy config external to repo | Env drift | Open |

View File

@ -5,7 +5,7 @@ # Spec Candidates
>
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-04-24 (added a strategic Governance Platform Foundation priority note, clarified the near-term sequencing for control-catalog, provider-boundary, operation-type, and governed-subject hardening, and added `Customer Review Workspace v1` as the customer-facing review consumption candidate that sharpens the R2 read-only/customer review lane)
> **Last reviewed**: 2026-04-25 (added Codebase Quality & Engineering Maturity cluster from full codebase audit with System Panel Least-Privilege Capability Model, Static Analysis Baseline, Architecture Boundary Guard Tests, Filament Hotspot Decomposition Foundation, and RestoreService Responsibility Split; retained OperationRun UX Consistency and Provider Boundary hardening sequences as current strategic hardening lanes)
---
@ -52,6 +52,7 @@ ## Promoted to Spec
- Finding Outcome Taxonomy & Verification Semantics → Spec 231 (`finding-outcome-taxonomy`)
- Operation Run Link Contract Enforcement → Spec 232 (`operation-run-link-contract`)
- Operation Run Active-State Visibility & Stale Escalation → Spec 233 (`stale-run-visibility`)
- Provider Boundary Hardening → Spec 237 (`provider-boundary-hardening`)
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
@ -69,33 +70,333 @@ ## Qualified
>
> Recommended next sequence:
>
> 1. **Canonical Control Catalog Foundation**
> 2. **Provider Boundary Hardening**
> 3. **Provider Identity & Target Scope Neutrality**
> 4. **Canonical Operation Type Source of Truth**
> 5. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
> 6. **Customer Review Workspace v1**
> 1. **Provider Identity & Target Scope Neutrality**
> 2. **Canonical Operation Type Source of Truth**
> 3. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
> 4. **Customer Review Workspace v1**
>
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. The immediate risk is not missing feature breadth, but semantic drift: provider-specific identity becoming platform truth, operation-type dual semantics, governed-subject key leakage, and control meaning being hardcoded locally instead of anchored in a canonical catalog.
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining risk is semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage.
> Codebase Quality & Engineering Maturity cluster: these candidates come from the full codebase quality audit on 2026-04-25. The audit classified the repo as **good / product-capable, not bad coding**, but identified a small set of structural risks that should be handled before larger feature expansion: coarse System Panel platform visibility, missing static-analysis gates, thin architecture-boundary enforcement, and several large Filament/service hotspots. This cluster is intentionally hardening-focused; it must not become a broad rewrite or cosmetic cleanup campaign.
### System Panel Least-Privilege Capability Model
- **Type**: security hardening / platform-plane RBAC
- **Source**: full codebase quality audit 2026-04-25 — tenant/workspace-plane isolation is strong, but System Panel directory visibility is intentionally global and currently gated by coarse platform capabilities
- **Problem**: The System Panel currently exposes global workspace and tenant directory views through broad platform capabilities. This is acceptable for trusted platform superadmins and break-glass operators, but too coarse for enterprise-grade least-privilege support roles, audit expectations, and future support delegation.
- **Why it matters**: TenantPilot has strong tenant/workspace isolation elsewhere. If the platform plane remains coarse, the product has an uneven security story: customer-facing tenant access is tight, while internal/operator metadata visibility can still be broader than necessary. Enterprise customers, MSP operators, and auditors will expect support roles to see only the minimum system metadata needed for their task.
- **Proposed direction**:
- split broad System Panel directory visibility into more granular platform capabilities
- distinguish System Panel access, workspace directory visibility, tenant directory visibility, operations visibility, support diagnostics, and break-glass access
- keep platform superadmin and emergency break-glass behavior intact
- enforce the new boundaries server-side on System Panel pages, not only through navigation hiding
- add explicit tests for restricted platform users so unrelated workspace/tenant metadata cannot be enumerated accidentally
- **Candidate capabilities**:
- `platform.system.access`
- `platform.workspaces.view`
- `platform.tenants.view`
- `platform.operations.view`
- `platform.support_diagnostics.view`
- `platform.break_glass.use`
- **Scope boundaries**:
- **In scope**: System Panel page access, platform capability split, server-side authorization checks, navigation visibility alignment, audit-friendly role behavior, and regression tests for non-superadmin platform users
- **Out of scope**: redesigning tenant/workspace membership RBAC, changing admin-panel tenant isolation semantics, removing break-glass, adding impersonation, or building a full support-role management UI unless explicitly needed for test fixtures
- **Acceptance points**:
- existing platform superadmin behavior remains intact
- a platform user with only workspace-directory visibility cannot view tenant-directory pages
- a platform user with only tenant-directory visibility cannot view workspace-directory pages unless explicitly granted
- operations visibility is separately controllable from directory visibility
- System Panel pages return forbidden or not-found consistently when capability is missing
- tests prove navigation hiding is not the only protection
- **Risks / open questions**:
- Over-fragmenting capabilities could make platform-user administration noisy before there is a polished role UI
- The product needs an explicit decision on whether support diagnostics can reveal tenant metadata without full tenant-directory access
- Break-glass behavior must remain simple, auditable, and unmistakably separate from normal support access
- **Dependencies**: `PlatformCapabilities`, System Panel providers/pages, platform-user model/policies, existing System Directory tests, existing tenant/workspace isolation tests
- **Related specs / candidates**: enterprise auth structure, platform superadmin / break-glass rules, RBAC hardening, System Directory residual surface tests
- **Strategic sequencing**: First item in this cluster because it is the only finding with direct enterprise security / least-privilege implications.
- **Priority**: high
### Static Analysis Baseline for Platform Code
- **Type**: quality gate / developer experience hardening
- **Source**: full codebase quality audit 2026-04-25 — the repo has strong Pest and lane-based tests but no visible PHPStan/Larastan/Psalm/Rector gate
- **Problem**: Runtime tests and feature tests are strong, but the codebase lacks a visible static-analysis baseline. In a growing Laravel / Filament / Livewire codebase with large services and resources, relying only on runtime tests leaves type drift, unsafe API usage, dead paths, and refactoring regressions too easy to introduce.
- **Why it matters**: TenantPilot is increasingly agent-assisted and spec-driven. Agents can move quickly, but without static analysis they can also reinforce invalid assumptions across dynamic Laravel boundaries. A pragmatic static-analysis gate gives both humans and agents a fast feedback loop before full suites run.
- **Proposed direction**:
- add Larastan/PHPStan configuration for `apps/platform`
- start at a realistic level rather than attempting perfect strictness on day one
- generate an explicit baseline if existing findings are too broad for immediate cleanup
- make CI fail on new non-baselined findings
- document the local and CI workflow for developers and repo agents
- track baseline reduction as a future maintenance path rather than bundling all fixes into this spec
- **Scope boundaries**:
- **In scope**: PHPStan/Larastan setup, baseline generation if needed, CI integration, developer documentation, and a small number of configuration fixes required to make analysis meaningful for Laravel/Filament patterns
- **Out of scope**: fixing all existing static-analysis findings, broad refactoring, Rector-driven code rewrites, changing app architecture, or blocking unrelated feature delivery on full strictness immediately
- **Acceptance points**:
- static analysis runs locally for `apps/platform`
- static analysis runs in CI or the active repository pipeline
- existing accepted findings are captured in a reviewed baseline
- new non-baselined findings fail the quality gate
- README, handover, or developer docs explain how to run and update the baseline
- configuration accounts for Laravel, Filament, Eloquent factories, and dynamic container usage where appropriate
- **Risks / open questions**:
- Starting too strict could create a large noisy cleanup spec instead of a useful guardrail
- Starting too loose could give false confidence without catching meaningful drift
- The repo must decide whether PHPStan/Larastan is enough initially or whether Rector belongs in a later separate modernization lane
- **Dependencies**: current Composer tooling, Pest lanes, Gitea workflows, `apps/platform/phpunit.xml`, developer documentation
- **Related specs / candidates**: Architecture Boundary Guard Tests, codebase quality hardening, CI/DX hardening
- **Strategic sequencing**: Second item in this cluster. It should land before broad hotspot refactors so those refactors have stronger safety rails.
- **Priority**: high
### Architecture Boundary Guard Tests
- **Type**: architecture hardening / regression guardrail
- **Source**: full codebase quality audit 2026-04-25 — product tests are strong, but architecture-level enforcement is still thin compared with the size and complexity of the codebase
- **Problem**: The repo has strong feature, RBAC, browser, and operation-flow tests, but only limited architecture-boundary enforcement. As the platform grows, Filament UI, services, jobs, provider code, models, support registries, and operation-run semantics can drift silently unless dependency and responsibility rules are executable.
- **Why it matters**: TenantPilot already has clear architectural intent: UI should not become provider-write logic, jobs should delegate business logic, platform and tenant capabilities should remain separate, and operation-run semantics should stay service-owned. Without guard tests, these principles remain review conventions and can be weakened by future agent-led changes.
- **Proposed direction**:
- introduce architecture tests that encode the most important dependency and responsibility boundaries
- start with high-signal rules rather than broad brittle pattern matching
- baseline or explicitly document accepted legacy violations
- connect new tests to the active quality-gate lane
- use the tests as a safety rail before decomposing large Filament/service hotspots
- **Candidate guardrails**:
- Filament Resources must not directly perform provider writes
- Filament Resources must not own large workflow orchestration
- Jobs should delegate business logic to services or handlers
- provider-specific code must not leak into neutral platform domains
- Models must not depend on Filament
- Services must not depend on Filament Resources
- Support registries must not depend on UI classes
- platform capabilities and tenant/workspace capabilities must remain separated
- OperationRun lifecycle and outcome semantics stay service-owned
- **Scope boundaries**:
- **In scope**: executable architecture tests, pragmatic baselines/exceptions, dependency-direction checks, responsibility-boundary checks, and CI integration
- **Out of scope**: perfect clean-architecture purity, mass refactoring to satisfy idealized rules, changing Laravel/Filament conventions where the framework reasonably expects dynamic coupling, or enforcing line-count thresholds as the only quality metric
- **Acceptance points**:
- architecture tests run locally and in CI
- new violations for selected boundaries fail tests
- accepted existing violations are explicitly documented with exit paths or reasons
- tests protect at least UI/provider, model/UI, service/UI, platform-capability, and OperationRun ownership boundaries
- the rules are specific enough to guide future agent work without blocking legitimate Laravel/Filament usage
- **Risks / open questions**:
- Over-broad static rules may produce noise and encourage blanket exceptions
- Some legacy hotspots may need temporary exceptions until decomposition specs land
- The tests should complement, not duplicate, PHPStan/Larastan
- **Dependencies**: Static Analysis Baseline for Platform Code, current architecture test setup, existing Action Surface guard tests, platform capability registry, provider contracts
- **Related specs / candidates**: Static Analysis Baseline for Platform Code, Filament Hotspot Decomposition Foundation, RestoreService Responsibility Split, Provider Boundary Hardening
- **Strategic sequencing**: Third item in this cluster. It can begin alongside static analysis but should be in place before large decomposition work accelerates.
- **Priority**: high
### Filament Hotspot Decomposition Foundation
- **Type**: maintainability hardening / UI architecture
- **Source**: full codebase quality audit 2026-04-25 — several Filament Resources/Pages are large multi-responsibility hotspots despite an otherwise structured architecture
- **Problem**: Several Filament surfaces have grown into large classes that combine table/query construction, form or infolist schema, action definitions, presentation rules, state labels, authorization glue, notifications, and workflow orchestration. This does not make the codebase bad, but it increases review cost, bus-factor risk, regression risk, and future feature cost.
- **Known hotspots**:
- `ManagedTenantOnboardingWizard.php`
- `TenantResource.php`
- `FindingResource.php`
- `RestoreRunResource.php`
- **Why it matters**: Filament is the primary operator UI. If every major surface keeps accumulating local query, action, presenter, and workflow code, the admin experience becomes hard to evolve safely. This is especially risky in an agent-led workflow where large files encourage local patching rather than clean extraction.
- **Proposed direction**:
- define a repeatable decomposition pattern for large Filament Resources and Pages
- extract complex query builders into dedicated query/read-model objects where useful
- extract action construction into action builder classes or surface-specific action objects
- extract badge, label, state, and helper-text rules into presenters
- extract complex form/infolist/table section schemas into reusable schema builders
- keep routes, resource names, permissions, and user-facing behavior unchanged during the foundation slice
- adopt the pattern on one representative Resource first before migrating all hotspots
- **First adoption target**: Prefer `FindingResource.php` or `TenantResource.php` as the first representative target because both expose dense operator-facing surfaces and repeated action/presentation/query patterns.
- **Scope boundaries**:
- **In scope**: decomposition pattern, first representative Resource/Page adoption, tests proving behavior is unchanged, and one or more architecture guardrails that prevent immediate regression
- **Out of scope**: broad UI redesign, changing product behavior, permission-semantic changes, schema changes, visual redesign, or mass migration of every large Filament surface in one spec
- **Acceptance points**:
- selected Resource/Page loses meaningful line count without changing behavior
- extracted classes have clear responsibility names and are easier to test or review
- existing UI/feature tests pass unchanged or are updated only for intentional structure-aware guardrails
- new or updated architecture tests prevent action/query/presenter logic from growing back into the Resource in the same form
- the resulting pattern is documented so future specs and agents can reuse it
- **Risks / open questions**:
- Extracting too aggressively could create more indirection than clarity
- Extracting too little would reduce line count without actually improving responsibility boundaries
- Choosing the first adoption surface matters; a volatile feature surface may make behavior-preserving decomposition harder
- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, existing Filament resource tests, action-surface guard tests
- **Related specs / candidates**: Record Page Header Discipline & Contextual Navigation (Spec 192), Monitoring Surface Action Hierarchy & Workbench Semantics (Spec 193), Governance Friction & Operator Vocabulary Hardening (Spec 194), RestoreService Responsibility Split
- **Strategic sequencing**: Fourth item in this cluster. It should follow static analysis and initial architecture guardrails so the extraction work is safer and easier to review.
- **Priority**: high
### RestoreService Responsibility Split
- **Type**: maintainability hardening / safety-critical workflow architecture
- **Source**: full codebase quality audit 2026-04-25 — restore logic is safety-critical but currently concentrated in a large service hotspot
- **Problem**: `RestoreService.php` has grown into a large multi-responsibility class. Restore is one of TenantPilot's highest-risk workflows because it can affect customer tenant state. Concentrating preview, validation, payload mapping, provider writes, operation tracking, result normalization, and failure classification in one service increases regression risk and makes review harder.
- **Why it matters**: Restore is not just another CRUD operation. Operators need predictable preview/apply semantics, accurate failure handling, and auditable operation results. A large service can still work, but it becomes increasingly difficult to change safely, especially as provider-backed actions and restore semantics mature.
- **Proposed direction**:
- keep `RestoreService` as a thin application-facing facade if preserving its public API is useful
- extract restore preview calculation into a focused collaborator
- extract restore payload mapping into provider-aware mappers
- extract restore validation / precondition checks into a dedicated validator or gate
- extract provider write execution into explicit execution handlers
- extract restore result normalization and failure classification into focused components
- preserve existing OperationRun and audit semantics
- **Target responsibility slices**:
- restore preview calculation
- restore payload mapping
- restore validation and preconditions
- provider write execution
- restore operation/run tracking
- restore result normalization
- restore failure classification
- **Scope boundaries**:
- **In scope**: internal responsibility split, behavior-preserving tests, collaborator extraction, thin facade preservation where appropriate, and restore-specific architecture guardrails
- **Out of scope**: changing restore UI, changing provider behavior, changing restore operation semantics, adding new restore features, broad provider abstraction redesign, or rewriting the restore engine from scratch
- **Acceptance points**:
- `RestoreService.php` becomes materially smaller
- each extracted class has one clear responsibility
- existing restore tests pass
- new tests cover at least preview, validation/preconditions, provider write execution, and failure/result handling boundaries
- OperationRun lifecycle and audit behavior remain unchanged
- the public restore workflow remains behavior-compatible unless an explicit spec requirement says otherwise
- **Risks / open questions**:
- Restore has real execution risk; decomposition must be behavior-preserving and heavily tested
- Poor extraction could hide execution order or transactional semantics across too many classes
- Provider-boundary cleanup and restore decomposition must be coordinated so neither creates competing abstractions
- **Dependencies**: Static Analysis Baseline for Platform Code, Architecture Boundary Guard Tests, restore tests, OperationRun semantics, Provider Boundary Hardening
- **Related specs / candidates**: Restore Lifecycle Semantic Clarity, Provider-Backed Action Preflight and Dispatch Gate Unification (Spec 216), Provider Boundary Hardening (Spec 237), Filament Hotspot Decomposition Foundation
- **Strategic sequencing**: Fifth item in this cluster. It should follow or run shortly after the generic quality gates, but it can be promoted earlier if restore changes become frequent.
- **Priority**: high
> Recommended sequence for this cluster:
> 1. **System Panel Least-Privilege Capability Model**
> 2. **Static Analysis Baseline for Platform Code**
> 3. **Architecture Boundary Guard Tests**
> 4. **Filament Hotspot Decomposition Foundation**
> 5. **RestoreService Responsibility Split**
>
> Why this order: first close the enterprise security/least-privilege gap, then add quality gates, then protect architecture boundaries, and only then start behavior-preserving decomposition of the largest UI/service hotspots. This avoids a broad rewrite while directly addressing the audit's highest-leverage risks.
> Platform Hardening — OperationRun UX Consistency cluster: these candidates prevent OperationRun-starting features from drifting into surface-local UX behavior. The goal is not to rebuild the Operations Hub, progress system, or notification architecture in one step. The immediate priority is to make OperationRun start UX contract-driven so new features cannot hand-roll local toasts, operation links, browser events, and queued-notification decisions independently.
### OperationRun Start UX Contract
- **Type**: hardening / architecture guardrail
- **Source**: OperationRun UX consistency analysis 2026-04-24 — `Refresh evidence` creates `tenant.evidence.snapshot.generate` runs correctly but does not consistently expose the same start UX as other OperationRun-backed flows
- **Problem**: OperationRun lifecycle state and terminal notifications are partially centralized, but the start UX is still assembled per Filament surface. Different flows independently decide local toast copy, `Open operation` links, run-enqueued browser events, dedup/no-op messaging, artifact links, and queued DB-notification behavior. This creates UX drift whenever a new feature starts, deduplicates, blocks, or links to an OperationRun.
- **Why it matters**: TenantPilot uses OperationRun as canonical execution truth. If every feature surface composes its own start UX, operators receive inconsistent signals for the same execution concept, and agent-led development will repeatedly miss one of the required pieces. Governance flows such as evidence refresh, review-pack generation, baseline capture/compare, inventory sync, backup, and restore need one reusable start contract instead of surface-local conventions.
- **Proposed direction**:
- introduce a central OperationRun start result contract that can represent new run queued, already queued, already running, already available/deduped, blocked, and failed-to-start states
- introduce a central presenter that turns the start result into consistent Filament notifications, `Open operation` links, artifact links, and browser-event decisions
- keep OperationRun detail links routed through the existing canonical link resolver rather than manually composing URLs
- keep queued DB-notifications explicit opt-in; do not globally enable queued database notifications as part of this slice
- preserve existing terminal notification behavior through the central OperationRun lifecycle
- migrate `Refresh evidence` as the first adoption and `Review pack generate` or `Create snapshot` as the second adoption
- add a guard test that prevents new hand-rolled OperationRun start-success UX in selected Filament/Livewire surfaces unless a spec explicitly allows an exception
- **Scope boundaries**:
- **In scope**: OperationRun start result contract, generic start-result presenter, `Refresh evidence` adoption, one second adoption, `Open operation` action consistency, run-enqueued browser event dispatch, queued DB-notification opt-in decision, dedup/no-op copy, guard-test coverage, and minimal spec/template guidance where appropriate
- **Out of scope**: generic progress-system redesign, `BulkOperationProgress` rename or rewrite, global queued notification policy, migration of every existing OperationRun start surface, new OperationRun types, and broad Operations Hub redesign
- **Acceptance points**:
- `Refresh evidence` distinguishes new-run from dedup/already-available outcomes in its returned start result and UI copy
- a new `tenant.evidence.snapshot.generate` run started from the UI exposes an `Open operation` action and dispatches the central run-enqueued browser event
- unchanged fingerprint / already available snapshot does not claim that a new run was queued, links the existing snapshot, and does not dispatch a run-enqueued event
- queued DB-notification remains opt-in and is not accidentally enabled globally
- terminal completed/failed/blocked notification behavior does not regress
- at least one second flow, preferably `Review pack generate`, proves the contract is not evidence-specific
- a guard test blocks new local OperationRun start-success toasts that bypass the central presenter, with explicitly documented legacy exceptions
- **Risks / open questions**:
- Over-broad static guard rules could flag legitimate non-OperationRun notifications; the first guard should be pragmatic and exception-based rather than attempting perfect static analysis
- Provider-backed flows already have a stronger gate/presenter pattern; the new generic contract should reuse or align with that pattern rather than create a competing presenter stack
- Existing manual flows should not all be migrated in this spec, otherwise the slice becomes too large
- **Dependencies**: `OperationRunService`, `OperationRunLinks`, `OperationRunUrl`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`, `OpsUxBrowserEvents`, `EvidenceSnapshotService`, `ReviewPackService`, existing OperationRun notification tests, existing link contract guard tests
- **Related specs / candidates**: Operation Run Link Contract Enforcement (Spec 232), Operation Run Active-State Visibility & Stale Escalation (Spec 233), Provider-Backed Action Preflight and Dispatch Gate Unification (Spec 216), OperationRun Notification Lifecycle, Generic Active Run Surface, OperationRun Startsurface Migration
- **Strategic sequencing**: First item in the OperationRun UX Consistency cluster. It should land before additional large evidence, review, baseline, or governance operations are added, so new OperationRun-backed features inherit the same start contract from the beginning.
- **Priority**: high
### Generic Active Run Surface
- **Type**: hardening / monitoring UX
- **Source**: OperationRun UX consistency analysis 2026-04-24 — current active-run surface is historically named `BulkOperationProgress` and does not clearly separate true progress metrics from active-run visibility
- **Problem**: The global run/progress surface is historically shaped around bulk operations, while the product now has many OperationRun types. Some runs have meaningful progress metrics, while others only have queued/running/completed state. The current surface can be refreshed by run-enqueued browser events, but not every run-starting flow dispatches those events, and the widget does not consistently model whether a run is progress-capable or active-status-only.
- **Why it matters**: Operators need immediate confidence that a started operation is visible and still active, but the UI must not imply fake percent progress for jobs that do not expose real progress. As OperationRun becomes the execution truth for evidence, reviews, baselines, inventory, backup, restore, and provider checks, the active-run surface must communicate the right level of detail without noise.
- **Proposed direction**:
- classify OperationRun types by display capability: progress-capable, active-status-only, or terminal-only
- decide whether `BulkOperationProgress` remains bulk-specific, is wrapped by a generic active-run surface, or is gradually renamed/refactored
- display progress bars only for runs with real progress counters
- display simple queued/running indicators for short or active-status-only runs
- ensure run-enqueued events from the OperationRun Start UX Contract refresh the active-run surface reliably
- keep tenant/workspace filtering explicit so the surface never leaks cross-context runs
- **Scope boundaries**:
- **In scope**: active-run surface semantics, progress-capability classification, polling rules, browser-event refresh behavior, active-status-only rendering, and tenant/workspace filtering
- **Out of scope**: OperationRun start contract itself, DB-notification lifecycle policy, external alerting, and migration of all start surfaces
- **Acceptance points**:
- every displayed run type is classified as progress-capable or active-status-only
- runs without real progress metrics are not rendered with misleading percent/progress values
- active run polling remains quiet when no relevant active runs exist
- run-enqueued events refresh the surface consistently for relevant active runs
- tenant/workspace scoping is enforced in queries and tests
- **Dependencies**: OperationRun Start UX Contract, `BulkOperationProgress`, `ActiveRuns`, `OpsUxBrowserEvents`, `OperationRun`, `OperationRunType`
- **Related specs / candidates**: Operation Run Active-State Visibility & Stale Escalation (Spec 233), OperationRun Start UX Contract, OperationRun Startsurface Migration
- **Strategic sequencing**: Second item in the OperationRun UX Consistency cluster, after the start contract establishes when run-enqueued events should be emitted.
- **Priority**: medium-high
### OperationRun Notification Lifecycle
- **Type**: hardening / notification policy
- **Source**: OperationRun UX consistency analysis 2026-04-24 — queued DB-notifications exist but are not broadly used, while terminal notifications are handled centrally through the OperationRun lifecycle
- **Problem**: OperationRun notifications currently mix clear terminal lifecycle behavior with unclear queued/running policy. Queued DB-notifications are technically available but intentionally opt-in and rarely used in app code. Terminal completed/failed/blocked messaging is more centralized, but failed and blocked states are still communicated through a shared terminal notification path rather than a clearly documented lifecycle policy.
- **Why it matters**: Persisted DB-notifications can quickly become noisy in an MSP/operator product. At the same time, long-running or critical operations may need durable notifications beyond local toasts. The product needs an explicit policy for when a run deserves a persisted queued/running/terminal notification, who receives it, and how duplicates are prevented.
- **Proposed direction**:
- define a lifecycle notification policy for queued, running, completed, failed, blocked, and any relevant stale/canceled states
- keep queued DB-notifications opt-in unless the spec defines precise criteria for enabling them
- clarify whether terminal states should continue through a shared `OperationRunCompleted` channel or split failed/blocked into explicit notification classes
- define recipient rules: initiator-only, workspace members, tenant members, or capability-based recipients
- define duplicate-prevention and one-terminal-notification guarantees
- decide whether Filament database notifications should poll or remain manually refreshed
- **Scope boundaries**:
- **In scope**: DB-notification lifecycle policy, recipient policy, queued opt-in criteria, terminal notification semantics, duplicate prevention, and panel polling decision
- **Out of scope**: Teams/email alert routing, findings escalation rules, external webhook/PSA notifications, generic progress surface, and the OperationRun start-result contract itself
- **Acceptance points**:
- lifecycle notification policy is explicit for queued/running/completed/failed/blocked states
- queued DB-notifications remain controlled and are not globally enabled accidentally
- terminal notifications are emitted exactly once per relevant run lifecycle
- failed and blocked terminal messaging is clear to operators
- recipient selection is documented and tested
- Filament DB-notification polling is intentionally enabled or intentionally disabled with tests
- **Dependencies**: OperationRun Start UX Contract, `OperationRunService`, `OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`, Filament panel providers
- **Related specs / candidates**: Findings Notifications & Escalation v1 (Spec 224), Findings Notification Presentation Convergence (Spec 230), OperationRun Start UX Contract, Generic Active Run Surface
- **Strategic sequencing**: Third item in the OperationRun UX Consistency cluster. It should follow the start contract so local start toasts and persisted DB-notifications remain clearly separated.
- **Priority**: medium
### OperationRun Startsurface Migration
- **Type**: hardening / migration slice
- **Source**: OperationRun UX consistency analysis 2026-04-24 — many existing OperationRun-starting surfaces already implement partial local patterns and should be migrated gradually after the shared contract exists
- **Problem**: Even after a central OperationRun Start UX Contract exists, older start surfaces will continue to contain manual toast, link, dedup, browser-event, and artifact-link behavior. If they remain indefinitely, they become examples for future features and keep the UX inconsistent.
- **Why it matters**: The contract prevents new drift, but existing drift still affects daily operator experience. A controlled migration strand lets the product converge without turning the initial contract spec into an oversized refactor.
- **Proposed direction**:
- inventory OperationRun-starting surfaces and classify them as migrated, legacy/manual allowed, out of scope, or not actually OperationRun-starting
- reduce guard-test exceptions over time
- migrate flows in priority order: remaining Evidence/Review Pack surfaces, Baseline Capture, Baseline Compare, Inventory Sync, Backup Schedule, Backup Set bulk operations, Restore Execute, Provider Operations if still needed
- for each migrated flow, route toast, `Open operation`, artifact link, browser event, and queued DB-notification decision through the central contract
- preserve existing operation semantics while removing surface-local UX composition
- **Scope boundaries**:
- **In scope**: migration of existing OperationRun start surfaces to the PH.1 contract, reduction of guard exceptions, tests per migrated flow, and documentation of any permanent exceptions
- **Out of scope**: new OperationRun architecture, generic active-run surface design, notification lifecycle redesign, broad UI redesign, and changing job execution semantics
- **Acceptance points**:
- an inventory of known OperationRun-starting surfaces exists and is kept near the guard test or migration documentation
- migrated surfaces no longer hand-roll the combination of start toast, operation link, and browser event
- dedup/already-running/blocked states are represented through the central contract where applicable
- guard-test exceptions are reduced or justified with spec references
- each migrated flow has new-run and dedup/already-running tests where applicable
- **Dependencies**: OperationRun Start UX Contract; optionally Generic Active Run Surface and OperationRun Notification Lifecycle for later migration waves
- **Related specs / candidates**: OperationRun Start UX Contract, Generic Active Run Surface, OperationRun Notification Lifecycle, Provider-Backed Action Preflight and Dispatch Gate Unification (Spec 216)
- **Strategic sequencing**: Fourth item in the OperationRun UX Consistency cluster. It should not block PH.1 but should be used to retire manual legacy patterns incrementally.
- **Priority**: medium
> Recommended sequence for this cluster:
> 1. **OperationRun Start UX Contract**
> 2. **Generic Active Run Surface**
> 3. **OperationRun Notification Lifecycle**
> 4. **OperationRun Startsurface Migration**
>
> Why this order: first establish the mandatory start contract and guardrails, then clarify active-run visibility, then define durable notification policy, and only then migrate remaining legacy/manual surfaces in controlled waves.
> Provider-boundary / future-provider portability cluster: these candidates are intentionally **not** a multi-cloud execution program. The goal is to keep Microsoft-first hotspots small at shared platform seams so a later second provider remains a bounded follow-up instead of a rewrite. Current product truth stays Microsoft-first; these candidates only harden where provider-specific semantics are at risk of becoming platform-core truth.
### Provider Boundary Hardening
- **Type**: hardening / architecture boundary
- **Source**: provider portability audit 2026-04-23 — current foundation judged provider-agnostic enough with bounded Microsoft hotspots
- **Problem**: TenantPilot already has a generic-looking provider layer in some places, but shared seams such as provider gateways, operation orchestration, and supporting contracts can still inherit Graph- or Microsoft-shaped semantics by default. Without a bounded hardening pass, new work can keep treating provider-specific contracts as if they were platform-core truth.
- **Why it matters**: The immediate risk is not "no AWS/GCP support today". The real risk is silent deepening of provider coupling in shared contracts, which would make any later second-provider work expensive and cross-cutting.
- **Proposed direction**:
- classify touched seams as **provider-owned** or **platform-core**
- keep provider-specific contracts behind adapters or explicitly provider-owned services
- harden shared platform contracts around neutral concepts only where current workflows already cross provider-specific boundaries
- add review/test guardrails that prevent new platform-core Graph leakage
- **Scope boundaries**:
- **In scope**: contract-boundary audit, targeted extractions or normalizations at shared seams, review/test guardrails for new coupling
- **Out of scope**: full multi-provider runtime, AWS/GCP adapters, speculative provider registries, broad rebranding away from Microsoft/Intune vocabulary everywhere
- **Dependencies**: current `ProviderGateway` / `GraphClientInterface` seam, provider-boundary constitution guardrails
- **Priority**: high
### Provider Identity & Target Scope Neutrality
- **Type**: hardening / identity boundary
- **Source**: provider portability audit 2026-04-23 — `ProviderConnection` is generic by name but still carries Entra-shaped identity and target-scope semantics
@ -157,13 +458,12 @@ ### Provider Surface Vocabulary & Descriptor Cleanup
- **Priority**: medium
> Recommended sequence for this cluster:
> 1. **Provider Boundary Hardening**
> 2. **Provider Identity & Target Scope Neutrality**
> 3. **Governance Subject Taxonomy Decoupling**
> 4. **Compare Strategy Boundary Hardening**
> 5. **Provider Surface Vocabulary & Descriptor Cleanup**
> 1. **Provider Identity & Target Scope Neutrality**
> 2. **Governance Subject Taxonomy Decoupling**
> 3. **Compare Strategy Boundary Hardening**
> 4. **Provider Surface Vocabulary & Descriptor Cleanup**
>
> Why this order: the first three keep the foundation from becoming more provider-shaped. Compare and UI vocabulary cleanup remain valuable, but they are safer once shared contracts, identity scope, and governance taxonomy stop deepening Microsoft coupling.
> Why this order: Provider Boundary Hardening is now specced as the first bounded anti-drift pass. The remaining items keep that foundation from being weakened again at identity scope, governance taxonomy, compare fallback, and shared provider-facing vocabulary.
### Workspace Access Context and Navigation Cost Hardening
- **Type**: hardening

View File

@ -4,7 +4,7 @@ # Product Standards
> Specs reference these standards; they do not redefine them.
> Guard tests enforce critical constraints automatically.
**Last reviewed**: 2026-04-12
**Last reviewed**: 2026-04-24
---
@ -42,7 +42,7 @@ ## Related Docs
| Document | Location | Purpose |
|---|---|---|
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, UI-CONST-001, DECIDE-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
| Constitution | `.specify/memory/constitution.md` | Permanent principles (PROP-001, BLOAT-001, OPS-UX-START-001, UI-CONST-001, DECIDE-001, UI-SURF-001, ACTSURF-001, UI-HARD-001, UI-EX-001, HDR-001, OPSURF-001, UI-FIL-001, UX-001, Action Surface Contract, RBAC-UX) |
| Product Principles | `docs/product/principles.md` | High-level product decisions |
| Table Rollout Audit | `docs/ui/filament-table-standard.md` | Rollout inventory and implementation state from Spec 125 |
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Provider Boundary Hardening
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-24
**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
- Initial draft created from the prioritized candidate sequence in [docs/product/spec-candidates.md](../../../docs/product/spec-candidates.md) and [docs/product/roadmap.md](../../../docs/product/roadmap.md).
- Repo-required constitution and validation sections remain intentionally technical, but the feature scope, scenarios, requirements, and success criteria stay solution-agnostic.

View File

@ -0,0 +1,207 @@
openapi: 3.1.0
info:
title: Provider Boundary Hardening Logical Contract
version: 0.1.0
description: |
Logical internal contract for the first provider-boundary hardening slice.
It describes shared shapes for listing seam ownership, resolving operation
definition versus provider binding, and evaluating touched boundary changes.
It is not a commitment to expose public HTTP routes.
paths:
/logical/provider-boundaries/seams:
get:
summary: List the first-slice provider boundary seam ownership catalog
operationId: listProviderBoundarySeams
responses:
'200':
description: Boundary seam catalog
content:
application/json:
schema:
type: object
properties:
seams:
type: array
items:
$ref: '#/components/schemas/ProviderBoundarySeam'
required:
- seams
/logical/provider-boundaries/operations/{operationType}:
get:
summary: Read platform-core operation definition and current provider binding
operationId: getProviderBoundaryOperation
parameters:
- name: operationType
in: path
required: true
schema:
type: string
responses:
'200':
description: Operation definition and binding
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderOperationBoundaryResponse'
/logical/provider-boundaries/evaluate:
post:
summary: Evaluate whether a touched change respects the declared boundary
operationId: evaluateProviderBoundaryChange
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderBoundaryEvaluationRequest'
responses:
'200':
description: Boundary evaluation outcome
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderBoundaryCheckResult'
components:
schemas:
ProviderBoundaryOwner:
type: string
enum:
- provider_owned
- platform_core
ProviderBoundarySeam:
type: object
properties:
seam_key:
type: string
owner:
$ref: '#/components/schemas/ProviderBoundaryOwner'
description:
type: string
implementation_paths:
type: array
items:
type: string
neutral_terms:
type: array
items:
type: string
retained_provider_semantics:
type: array
items:
type: string
follow_up_action:
type: string
enum:
- none
- document-in-feature
- follow-up-spec
required:
- seam_key
- owner
- description
- implementation_paths
- neutral_terms
- retained_provider_semantics
- follow_up_action
ProviderOperationDefinition:
type: object
properties:
operation_type:
type: string
module_key:
type: string
label:
type: string
required_capability:
type: string
required:
- operation_type
- module_key
- label
- required_capability
ProviderOperationBinding:
type: object
properties:
operation_type:
type: string
provider:
type: string
binding_status:
type: string
enum:
- active
- unsupported
handler_notes:
type: string
exception_notes:
type: string
required:
- operation_type
- provider
- binding_status
ProviderOperationBoundaryResponse:
type: object
properties:
definition:
$ref: '#/components/schemas/ProviderOperationDefinition'
binding:
$ref: '#/components/schemas/ProviderOperationBinding'
required:
- definition
- binding
ProviderBoundaryEvaluationRequest:
type: object
properties:
seam_key:
type: string
file_path:
type: string
proposed_owner:
$ref: '#/components/schemas/ProviderBoundaryOwner'
provider_specific_terms:
type: array
items:
type: string
introduces_new_binding:
type: boolean
required:
- seam_key
- file_path
- proposed_owner
- provider_specific_terms
- introduces_new_binding
ProviderBoundaryCheckResult:
type: object
properties:
status:
type: string
enum:
- allowed
- review_required
- blocked
seam_key:
type: string
file_path:
type: string
violation_code:
type: string
enum:
- none
- platform_core_provider_leak
- undeclared_exception
- missing_provider_binding
- provider_binding_as_primary_truth
message:
type: string
suggested_follow_up:
type: string
enum:
- none
- document-in-feature
- follow-up-spec
required:
- status
- seam_key
- file_path
- violation_code
- message
- suggested_follow_up

View File

@ -0,0 +1,115 @@
# Data Model: Provider Boundary Hardening
## Overview
The first slice introduces one explicit seam-ownership catalog with binary ownership plus documented exception metadata, one split between shared operation definition and provider binding, and one deterministic guard result shape. No new database persistence is introduced.
## Entity: ProviderBoundarySeam
- **Purpose**: Declares whether a touched seam is provider-owned or platform-core, and records any documented current-release exception metadata that remains temporarily allowed.
- **Identity**:
- `seam_key` — stable catalog key such as `provider.gateway_runtime`, `provider.identity_resolution`, or `provider.operation_registry`
- **Core fields**:
- `owner`
- `description`
- `implementation_paths[]`
- `neutral_terms[]`
- `retained_provider_semantics[]`
- `follow_up_action`
- **Owner values**:
- `provider_owned`
- `platform_core`
- **Validation rules**:
- Every first-slice seam must have exactly one owner classification.
- `platform_core` seams must list the neutral platform terms they preserve.
- Any seam that retains provider-specific semantics must list them in `retained_provider_semantics[]` and pair them with an explicit `follow_up_action`.
- `implementation_paths[]` must reference real code paths used by tests and review guardrails.
### Authoritative first-slice seam inventory
- `provider.gateway_runtime``ProviderGateway.php`, `MicrosoftGraphOptionsResolver.php`
- `provider.identity_resolution``ProviderIdentityResolution.php`, `ProviderIdentityResolver.php`, `PlatformProviderIdentityResolver.php`
- `provider.connection_resolution``ProviderConnectionResolver.php`, `ProviderConnectionResolution.php`
- `provider.operation_registry``ProviderOperationRegistry.php`
- `provider.operation_start_gate``ProviderOperationStartGate.php`
## Entity: ProviderOperationDefinition
- **Purpose**: Represents the platform-core definition of a provider-backed operation without silently embedding provider truth.
- **Identity**:
- `operation_type`
- **Core fields**:
- `module_key`
- `label`
- `required_capability`
- **Validation rules**:
- The definition must not require a provider field as its primary identity.
- Labels and module keys remain shared orchestration truth and do not encode Microsoft-specific vocabulary by default.
- Operation type values remain unchanged in this slice.
## Entity: ProviderOperationBinding
- **Purpose**: Connects one shared operation definition to the current provider-owned runtime behavior.
- **Fields**:
- `operation_type`
- `provider`
- `binding_status`
- `handler_notes`
- `exception_notes`
- **Binding status values**:
- `active`
- `unsupported`
- **Validation rules**:
- Every binding must reference an existing `operation_type`.
- Missing binding is explicit and must not fall back silently to Microsoft behavior.
- Provider-specific notes remain secondary to the platform-core operation definition.
## Entity: ProviderBoundaryCheckResult
- **Purpose**: Shared deterministic result used by tests and review guardrails.
- **Fields**:
- `status`
- `seam_key`
- `file_path`
- `violation_code`
- `message`
- `suggested_follow_up`
- **Status values**:
- `allowed`
- `review_required`
- `blocked`
- **Violation code examples**:
- `platform_core_provider_leak`
- `undeclared_exception`
- `missing_provider_binding`
- `provider_binding_as_primary_truth`
- **Validation rules**:
- `allowed` means the touched seam matches its declared ownership.
- `review_required` means the seam has documented exception metadata or needs explicit follow-up.
- `blocked` means the touched change violates the seam ownership or provider-binding rules.
## Relationships
- One `ProviderBoundarySeam` may cover multiple implementation paths.
- One `ProviderOperationDefinition` may have one current `ProviderOperationBinding` in the first slice because only Microsoft runtime support exists today.
- One `ProviderBoundaryCheckResult` references exactly one seam evaluation, but a review may emit multiple results across multiple touched files.
## Lifecycle
### Boundary seam lifecycle
- `provider_owned`: provider-specific semantics are allowed inside the seam.
- `platform_core`: provider-specific semantics are blocked unless explicitly extracted.
- `platform_core` with documented exception metadata: named provider-specific details remain temporarily allowed only while the seam records the retained semantics and explicit follow-up action.
### Operation binding lifecycle
- `active`: current provider runtime is explicitly supported for the operation.
- `unsupported`: the operation definition exists, but the current seam must fail explicitly instead of inheriting default provider behavior.
## Rollout Model
- The first slice keeps the catalog in code, not in the database.
- Existing Microsoft-backed runtime behavior remains the only shipped provider binding.
- Shared identity resolution and operation registry cleanup happen before any persistence or UI neutrality changes.
- Deeper target-scope and provider-connection neutrality remain follow-up work.

View File

@ -0,0 +1,253 @@
# Implementation Plan: Provider Boundary Hardening
**Branch**: `237-provider-boundary-hardening` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/237-provider-boundary-hardening/spec.md`
**Note**: This plan keeps the slice intentionally narrow. It classifies the first high-risk shared provider seams, removes Graph-shaped request building from shared identity resolution, makes provider binding explicit in the shared operation registry path, and adds focused guardrails without introducing a second-provider runtime, new persistence, or new operator-facing surfaces.
## Summary
Add a config-seeded provider-boundary catalog plus a small `App\Support\Providers\Boundary` helper layer to classify the first hot seams as `provider_owned` or `platform_core`, with documented current-release exception metadata where needed. The authoritative first-slice seam inventory is locked to `provider.gateway_runtime`, `provider.identity_resolution`, `provider.connection_resolution`, `provider.operation_registry`, and `provider.operation_start_gate`. The implementation will harden two concrete hotspots: first, move Graph request-option shaping out of `ProviderIdentityResolution` and keep it inside provider-owned seams such as `ProviderGateway` and `MicrosoftGraphOptionsResolver`; second, split `ProviderOperationRegistry` into platform-core operation metadata plus explicit provider binding metadata so `ProviderOperationStartGate` no longer treats `microsoft` as silent platform-default truth. Existing Microsoft-backed flows stay intact, `entra_tenant_id` and platform app identity remain documented current-release exceptions for the follow-up identity-neutrality slice, and the boundary is enforced through focused unit plus feature guard tests.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
**Storage**: Existing PostgreSQL tables such as `provider_connections` and `operation_runs`; one new in-repo config catalog for provider-boundary ownership; no new database tables
**Testing**: Pest v4 unit and focused feature tests through Laravel Sail
**Validation Lanes**: `fast-feedback`, `confidence`
**Target Platform**: Laravel admin web application running in Sail on the existing `/admin`, `/admin/t/{tenant}`, and provider-backed operation surfaces
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
**Performance Goals**: Keep boundary evaluation deterministic and in-process, add no outbound call before existing provider-owned execution seams, and preserve current provider-backed runtime performance on supported Microsoft flows
**Constraints**: No new provider runtime, no broad provider marketplace abstraction, no schema or route redesign, no operation-type renaming, no new UI surface, no new Graph contract path, and no silent Microsoft fallback on touched shared seams
**Scale/Scope**: One config-backed boundary catalog, one small boundary support namespace, one shared identity-resolution cleanup, one shared operation-registry cleanup, and focused unit plus feature guard coverage
## Filament v5 Implementation Contract
- **Livewire v4.0+ compliance**: Preserved. This slice changes shared services, value objects, and guardrails only and introduces no legacy Livewire patterns.
- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`.
- **Global search coverage**: No new Filament Resource or Page is added, and no existing global-search posture changes in this slice. Provider connection surfaces remain on their current search posture.
- **Destructive actions**: No destructive action is added or changed. This slice does not introduce new Filament actions.
- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when later UI work introduces registered assets.
- **Testing plan**: Prove the slice with focused Pest unit coverage for seam classification and registry behavior plus focused feature coverage for current Microsoft runtime preservation, unsupported-path behavior, and boundary guardrails.
## UI / Surface Guardrail Plan
- **Guardrail scope**: workflow-only guardrail change
- **Native vs custom classification summary**: `N/A`
- **Shared-family relevance**: provider-backed execution seams, provider connection runtime semantics, shared architecture guards
- **State layers in scope**: none
- **Handling modes by drift class or surface**: `review-mandatory`
- **Repository-signal treatment**: `review-mandatory`
- **Special surface test profiles**: `N/A`
- **Required tests or manual smoke**: `functional-core`, `state-contract`
- **Exception path and spread control**: one named current-release exception boundary for Microsoft-specific target-scope and platform app identity semantics that remain until the follow-up identity-neutrality spec
- **Active feature PR close-out entry**: `Guardrail`
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: provider gateway/runtime access, provider identity resolution, provider connection validation, provider-backed operation registry and start gate, provider-owned reason and next-step semantics, and adjacent feature guard patterns
- **Shared abstractions reused**: existing provider services, existing `GraphClientInterface` contract, existing `ProviderOperationStartGate`, existing feature-guard patterns under `tests/Feature/Guards` such as `NoLegacyTenantGraphOptionsTest.php` and `NoLegacyTenantProviderFallbackTest.php`, and existing provider unit suites
- **New abstraction introduced? why?**: yes. A small boundary catalog and boundary descriptor layer are required because prose and generic class names alone are not machine-checkable and have not prevented drift.
- **Why the existing abstraction was sufficient or insufficient**: existing provider seams are sufficient as extension points, but insufficiently explicit about ownership. `ProviderIdentityResolution::graphOptions()` and `ProviderOperationRegistry` currently mix provider-specific semantics into shared paths.
- **Bounded deviation / spread control**: the only allowed retained deviation is the documented Microsoft-first identity/target-scope exception on existing provider connection data until the follow-up identity-neutrality slice lands
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: `GraphClientInterface` implementations, `ProviderGateway`, `MicrosoftGraphOptionsResolver`, and Intune-specific service calls that intentionally execute Microsoft behavior
- **Platform-core seams**: provider-boundary catalog, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderOperationRegistry` core operation definition path, `ProviderOperationStartGate` shared orchestration decisions
- **Authoritative first-slice seam inventory**:
- `provider.gateway_runtime` -> `ProviderGateway.php`, `MicrosoftGraphOptionsResolver.php`
- `provider.identity_resolution` -> `ProviderIdentityResolution.php`, `ProviderIdentityResolver.php`, `PlatformProviderIdentityResolver.php`
- `provider.connection_resolution` -> `ProviderConnectionResolver.php`, `ProviderConnectionResolution.php`
- `provider.operation_registry` -> `ProviderOperationRegistry.php`
- `provider.operation_start_gate` -> `ProviderOperationStartGate.php`
- **Neutral platform terms / contracts preserved**: provider, provider connection, target scope, operation type, operation module, required capability, provider binding, unsupported provider behavior
- **Retained provider-specific semantics and why**: `entra_tenant_id`, platform app credential config, redirect callback details, and Microsoft Graph request-option keys remain current-release Microsoft semantics because they are still needed for the only shipped provider runtime today
- **Bounded extraction or follow-up path**: `follow-up-spec` for Provider Identity & Target Scope Neutrality; this feature documents and bounds the remaining identity-shaped hotspot instead of solving schema and UI neutrality here
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with one config-backed seam catalog, one bounded runtime extraction, and no new persistence or operator surface.*
| Gate | Status | Plan Notes |
|------|--------|------------|
| Inventory-first / read-write separation | PASS | The slice hardens contracts and runtime boundaries only. No new write path, preview flow, or operator mutation surface is introduced. |
| Single Graph contract path / no inline remote work | PASS | Existing Graph calls remain behind `GraphClientInterface`. The slice only relocates Graph option shaping to provider-owned seams and adds no new contract bypass. |
| RBAC, workspace isolation, tenant isolation | PASS | No new route, capability, or authorization plane is introduced. Existing provider-backed workflows keep their current tenant and workspace guards. |
| Run observability / Ops-UX lifecycle | PASS | The feature may touch `ProviderOperationStartGate`, but it does not create a new `OperationRun` type or change start-surface UX semantics. Existing service-owned run lifecycle rules remain intact. |
| Shared pattern first | PASS | The implementation reuses existing provider services and the existing guard-test pattern instead of creating a parallel portability framework. |
| Proportionality / no premature abstraction | PASS | One boundary catalog plus small descriptors are the narrowest machine-checkable source of truth for multiple real seams. No plugin system, provider marketplace, or second runtime is introduced. |
| Persisted truth / behavioral state | PASS | No new table or persisted lifecycle is added. One or two new provider-boundary reason codes may be introduced only if explicit unsupported-path behavior needs stable runtime semantics. |
| Provider boundary | PASS | The plan explicitly separates provider-owned seams from platform-core seams and records one bounded Microsoft-first exception path. |
| Filament v5 / Livewire v4 contract | PASS | No new Filament surface, action, or global-search behavior is introduced. Provider registration remains in `bootstrap/providers.php`. |
| Test governance | PASS | Coverage stays in focused unit plus feature lanes with no browser or heavy-governance expansion. |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for seam classification, registry split semantics, and retained exception behavior; `Feature` for current Microsoft-backed runtime preservation, unsupported-path behavior, and boundary guardrails on touched shared seams
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The business risk is semantic drift inside shared code, not browser interaction. Unit tests prove classification and neutral-contract rules; feature tests prove current Microsoft-backed flows remain intact and unsupported shared-boundary cases fail explicitly.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- **Fixture / helper / factory / seed / context cost risks**: Minimal. Reuse existing `ProviderConnection` and tenant factories plus current provider unit tests. Do not introduce a new default provider world helper.
- **Expensive defaults or shared helper growth introduced?**: No. The boundary catalog stays config-backed and test fixtures remain opt-in.
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: `N/A`
- **Closing validation and reviewer handoff**: Reviewers should verify that `ProviderIdentityResolution` no longer shapes Graph request options, that shared operation metadata no longer treats `microsoft` as silent default truth, that the remaining Microsoft-specific identity fields are documented as exceptions, and that current Microsoft-backed starts still work through the hardened seams.
- **Budget / baseline / trend follow-up**: none expected
- **Review-stop questions**: Did any platform-core seam retain Graph request shaping? Did `ProviderOperationRegistry` still expose provider binding as primary platform truth? Did the slice widen into schema/UI neutrality or operation-type renaming? Did any new test helper make provider context implicit by default?
- **Escalation path**: `document-in-feature`
- **Active feature PR close-out entry**: `Guardrail`
- **Why no dedicated follow-up spec is needed**: The remaining identity-schema and UI neutrality work already has a named next candidate. This feature contains only the first bounded hardening pass.
## Project Structure
### Documentation (this feature)
```text
specs/237-provider-boundary-hardening/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── provider-boundary-hardening.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Models/
│ │ └── ProviderConnection.php
│ ├── Services/
│ │ ├── Graph/
│ │ │ ├── GraphClientInterface.php
│ │ │ └── MicrosoftGraphClient.php
│ │ └── Providers/
│ │ ├── MicrosoftGraphOptionsResolver.php
│ │ ├── PlatformProviderIdentityResolver.php
│ │ ├── ProviderConnectionResolution.php
│ │ ├── ProviderConnectionResolver.php
│ │ ├── ProviderGateway.php
│ │ ├── ProviderIdentityResolution.php
│ │ ├── ProviderIdentityResolver.php
│ │ ├── ProviderOperationRegistry.php
│ │ └── ProviderOperationStartGate.php
│ └── Support/
│ └── Providers/
│ └── Boundary/
├── config/
│ └── provider_boundaries.php
└── tests/
├── Feature/
│ ├── Providers/
│ └── Guards/
└── Unit/
└── Providers/
```
**Structure Decision**: Keep the entire slice inside the existing Laravel runtime in `apps/platform`. The only new top-level code shape is a small `Support/Providers/Boundary` namespace plus a config-backed seam catalog. Runtime changes stay inside the existing provider services and the shared provider operation registry path.
## Complexity Tracking
No constitutional violation is planned. One bounded complexity addition is tracked because the feature introduces a new source of truth for seam ownership.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| BLOAT-001 bounded boundary catalog | Multiple real shared seams now need one explicit, testable ownership source of truth so provider leakage stops depending on reviewer memory alone | Comments, prose-only notes, or local assertions would not be machine-checkable and would let each new seam drift independently |
## Proportionality Review
- **Current operator problem**: Shared provider-backed platform code can still silently become more Microsoft-shaped, which raises the cost and risk of future governance work even when current operator behavior still appears to work.
- **Existing structure is insufficient because**: generic class names and partial provider abstractions do not stop Graph request shaping, provider binding defaults, and Microsoft-specific semantics from leaking into shared resolution or orchestration paths.
- **Narrowest correct implementation**: add one config-backed seam catalog, extract Graph option shaping out of shared identity resolution, and separate provider binding from shared operation metadata at the existing registry/start-gate seam.
- **Ownership cost created**: maintain the seam catalog, preserve a small set of boundary guard tests, and keep the one documented Microsoft-first exception path explicit until the follow-up identity-neutrality work lands.
- **Alternative intentionally rejected**: a broad multi-provider framework or connector platform. It would import speculative runtime machinery before there is a second real provider case.
- **Release truth**: current-release truth with deliberate anti-drift preparation for the next provider-boundary follow-through specs
## Phase 0 Research Summary
- The first boundary hardening slice should use a small config-backed seam catalog, not a generic provider-plugin framework.
- `ProviderIdentityResolution::graphOptions()` is a concrete provider-leak hotspot because a shared resolution object currently shapes Microsoft Graph request options directly.
- `ProviderOperationRegistry` is a second concrete hotspot because shared operation definitions currently expose `microsoft` as if it were platform-default truth.
- Existing `ProviderGateway`, `MicrosoftGraphOptionsResolver`, and Intune-specific services are acceptable provider-owned seams for current Microsoft behavior.
- `entra_tenant_id`, platform app identity config, and callback/redirect details should remain explicit current-release exceptions here and be cleaned up in the follow-up identity-neutrality slice.
- Focused unit plus feature guard tests are sufficient; browser or heavy-governance coverage would add cost without proving unique behavior.
## Phase 1 Design Summary
- `research.md` records the boundary decisions that keep the slice narrow and explicit.
- `data-model.md` defines the seam ownership catalog, operation definition vs provider binding split, and boundary guard result shape.
- `contracts/provider-boundary-hardening.logical.openapi.yaml` defines the logical internal contract for listing seam ownership and evaluating boundary changes.
- `quickstart.md` records the narrow validation order and the intended code areas.
- `tasks.md` will sequence the work from seam-catalog foundation through shared identity and registry hardening to final guard coverage.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Implementation Strategy
### Phase A — Add the seam ownership catalog
**Goal**: Make the authoritative first-slice seams explicitly classifiable and testable.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/config/provider_boundaries.php` | Add the bounded seam catalog for `provider.gateway_runtime`, `provider.identity_resolution`, `provider.connection_resolution`, `provider.operation_registry`, and `provider.operation_start_gate`, classifying each as `provider_owned` or `platform_core` and recording retained-provider-semantic notes as exception metadata where needed. |
| A.2 | `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryOwner.php`, `ProviderBoundarySeam.php`, and `ProviderBoundaryCatalog.php` | Model seam ownership, allowed exceptions, and deterministic lookup for tests and runtime guard checks. |
| A.3 | `apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php` | Prove the catalog contains the intended first-slice seams and only the allowed ownership classifications. |
### Phase B — Move Graph request shaping behind provider-owned seams
**Goal**: Stop shared identity resolution from emitting Microsoft Graph-shaped runtime options directly.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Services/Providers/ProviderIdentityResolution.php` | Remove Graph request-option shaping from the shared resolution object and expose only the neutral runtime data the provider-owned seam needs. |
| B.2 | `apps/platform/app/Services/Providers/ProviderGateway.php` and `MicrosoftGraphOptionsResolver.php` | Own Graph option assembly inside provider-owned seams and reuse the shared resolution data without reintroducing platform-core Graph leakage. |
| B.3 | `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `PlatformProviderIdentityResolver.php`, and `ProviderConnectionResolver.php` | Keep current Microsoft-first identity semantics working while marking the remaining target-scope and platform-app details as explicit current-release exceptions. |
### Phase C — Split shared operation metadata from provider binding
**Goal**: Keep shared orchestration metadata platform-core while making provider binding explicit and bounded.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` | Separate platform-core operation definition fields from provider-binding fields so the shared definition does not treat `microsoft` as silent default truth. |
| C.2 | `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` | Consume the explicit provider binding, preserve current Microsoft-backed start behavior, and return explicit unsupported behavior when a touched shared seam has no provider-owned binding. |
| C.3 | `apps/platform/app/Support/Providers/ProviderReasonCodes.php` and adjacent translation helpers if needed | Add one narrow provider-boundary reason code only if explicit unsupported shared-boundary behavior needs stable runtime semantics. |
### Phase D — Add guardrails and preserve runtime behavior
**Goal**: Keep the boundary enforceable without widening the slice.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/tests/Unit/Providers/ProviderBoundaryGuardrailTest.php` | Prove platform-core seam rules, allowed exceptions, and registry split behavior are deterministic. |
| D.2 | `apps/platform/tests/Feature/Providers/ProviderBoundaryHardeningTest.php` | Prove a current Microsoft-backed workflow still succeeds through the hardened seams. |
| D.3 | `apps/platform/tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php` | Prove the touched shared seam fails explicitly rather than inheriting Microsoft default behavior when binding or ownership is absent. |
| D.4 | `specs/237-provider-boundary-hardening/quickstart.md` and `tasks.md` | Keep the validation order, exception boundary, and no-second-provider-runtime guardrail explicit. |
## Risks and Mitigations
- **Identity scope creep**: Boundary hardening could drift into full provider identity neutrality. Mitigation: keep `entra_tenant_id` and platform app identity as explicit current-release exceptions and defer schema/UI neutrality to the next spec.
- **Operation-type scope creep**: Registry cleanup could become operation-type canonicalization work. Mitigation: keep operation type values unchanged and limit this slice to ownership separation, not naming reform.
- **Guardrail overreach**: A broad filesystem scan could flag legitimate provider-owned Microsoft services. Mitigation: make the seam catalog the allowlist source of truth and keep the guard coverage focused on touched shared seams.
- **Runtime regression**: Moving Graph option shaping can break current Microsoft-backed flows. Mitigation: preserve and extend focused provider unit and feature coverage around the hardened gateway and registry paths.
## Post-Design Re-check
The feature remains constitution-compliant, Filament v5 and Livewire v4 compliant, and narrow. It introduces no new persistence, no new operator-facing page, no new provider runtime, and no operation-type renaming. The plan, research, data model, quickstart, contract, and later tasks align on one explicit seam catalog, one provider-owned Graph shaping boundary, one shared registry hardening step, and one bounded Microsoft-first exception path.

View File

@ -0,0 +1,84 @@
# Quickstart: Provider Boundary Hardening
## Goal
Implement the first provider-boundary hardening slice without introducing a second-provider runtime, schema churn, or new operator-facing UI.
## Implementation Sequence
1. Done: added the config-backed seam ownership catalog and the small boundary helper layer.
2. Done: removed Graph request-option shaping from `ProviderIdentityResolution` and kept it inside provider-owned gateway/options resolver seams.
3. Done: split shared operation definitions from provider bindings in `ProviderOperationRegistry` and made `ProviderOperationStartGate` consume explicit bindings.
4. Done: kept remaining Microsoft-first identity details documented as explicit current-release exceptions instead of widening into schema and UI neutrality.
5. Done: added focused unit and feature coverage proving current Microsoft behavior still works and unsupported shared-boundary paths fail explicitly.
## Suggested Code Areas
```text
apps/platform/app/Support/Providers/Boundary/
apps/platform/config/provider_boundaries.php
apps/platform/app/Services/Providers/
apps/platform/app/Services/Graph/
apps/platform/app/Models/ProviderConnection.php
apps/platform/tests/Unit/Providers/
apps/platform/tests/Feature/Providers/
```
## Authoritative Seam Inventory
- `provider.gateway_runtime`
- `provider.identity_resolution`
- `provider.connection_resolution`
- `provider.operation_registry`
- `provider.operation_start_gate`
## Verification Commands
Run the narrowest proving lane first:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php
```
Then run the bounded runtime and unsupported-path proof:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php
```
Then run the feature-guard proof that blocks provider leakage in platform-core seams:
```bash
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php
```
If PHP files were added or changed, finish with formatting:
```bash
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Review Focus
- Confirm `ProviderIdentityResolution` no longer emits Graph-shaped request options directly.
- Confirm `ProviderGateway` and `MicrosoftGraphOptionsResolver` are the provider-owned seams that now assemble Graph request options.
- Confirm `ProviderOperationRegistry` keeps platform-core operation definition separate from provider binding.
- Confirm unsupported touched seams fail explicitly instead of inheriting Microsoft default behavior.
- Confirm `entra_tenant_id` and platform app identity remain explicit current-release exceptions, not silent platform-core truth.
- Confirm no second-provider runtime, schema rewrite, or UI surface slipped into the slice.
## Guardrail Close-Out
- Validation completed before final handoff:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderGatewayTest.php tests/Unit/Providers/ProviderIdentityResolverTest.php tests/Unit/Providers/ProviderOperationStartGateTest.php`
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- Guardrails checked:
- No new Graph contract path.
- No new provider runtime or marketplace abstraction.
- `ProviderIdentityResolution` no longer exposes `graphOptions()` or `client_request_id`.
- `ProviderOperationRegistry` keeps platform-core definitions separate from explicit provider bindings.
- `ProviderOperationStartGate` blocks unsupported provider bindings with `provider_binding_unsupported` instead of falling back to Microsoft.
- No undocumented provider-shaped exception on touched platform-core seams.
- Close-out decision: `document-in-feature`. The remaining Microsoft-first identity and target-scope details are bounded in `provider_boundaries.php`, covered by guardrails, and intentionally left for the next provider identity/target-scope neutrality spec.
- Bounded follow-up: deeper provider identity and target-scope neutrality remains a separate next spec; no schema rewrite, UI vocabulary rewrite, or second-provider runtime is included here.

View File

@ -0,0 +1,42 @@
# Research: Provider Boundary Hardening
## Decision 1: Use a small config-backed seam catalog instead of a provider framework
- **Decision**: Model first-slice provider-boundary ownership in one repository config catalog plus a small boundary helper layer, not as a speculative multi-provider framework.
- **Rationale**: The current release needs explicit ownership and guardrails across multiple real seams more than it needs connector plugins, provider registries, or generic runtime extension points. A config-backed catalog is reviewable, deterministic, and easy to enforce in tests.
- **Boundary model note**: The catalog keeps seam ownership binary as `provider_owned` or `platform_core`. Any retained Microsoft-first behavior is recorded as seam metadata with an explicit follow-up action, not as a third ownership state.
- **Alternatives considered**:
- Prose-only documentation and comments: rejected because reviewers cannot enforce it mechanically and the same drift can reappear on the next seam.
- Full provider-plugin architecture: rejected because there is still only one shipped provider runtime.
## Decision 2: Keep Graph request shaping inside provider-owned seams
- **Decision**: Remove Graph request-option shaping from `ProviderIdentityResolution` and keep it inside provider-owned seams such as `ProviderGateway` and `MicrosoftGraphOptionsResolver`.
- **Rationale**: A shared identity-resolution object currently knows Microsoft Graph request-option keys and request-id generation details. That is provider-owned behavior and should not live on a platform-core result type.
- **Alternatives considered**:
- Leave `graphOptions()` on `ProviderIdentityResolution`: rejected because it preserves Graph semantics in a shared runtime type.
- Introduce a broad provider request-context framework: rejected because the narrower extraction into existing provider-owned seams is sufficient.
## Decision 3: Split shared operation definition from provider binding
- **Decision**: Keep platform-core operation metadata separate from provider binding metadata in `ProviderOperationRegistry` and the `ProviderOperationStartGate` path.
- **Rationale**: Operation type, module, label, and capability are shared orchestration truth. The fact that the current runtime binds those operations to `microsoft` is provider-owned current-release behavior and should be explicit rather than silent default truth.
- **Alternatives considered**:
- Keep a single registry array with `provider => microsoft` on every entry: rejected because it makes the current first provider look like a permanent platform default.
- Fold this work into operation-type canonicalization: rejected because this spec is about ownership boundaries, not renaming operation codes.
## Decision 4: Treat target-scope and platform app identity details as bounded current-release exceptions
- **Decision**: Keep `entra_tenant_id`, platform app credential config, and callback-specific details as explicit current-release exceptions in this slice instead of widening into schema and UI neutrality.
- **Rationale**: These are real hotspots, but the next candidate `Provider Identity & Target Scope Neutrality` exists specifically to clean up the deeper persistence and operator-vocabulary consequences. This slice should harden the boundary before it rewrites identity truth.
- **Alternatives considered**:
- Rename storage and UI semantics now: rejected because it would widen the slice into a second spec.
- Ignore the hotspot entirely: rejected because the plan needs one documented exception boundary rather than pretending the issue is solved.
## Decision 5: Enforce the boundary with focused unit and feature guardrails
- **Decision**: Prove the hardening with narrow unit and feature tests that exercise seam classification, provider-binding behavior, unsupported-path behavior, and Microsoft runtime preservation.
- **Rationale**: The risk is architectural drift inside shared services, not browser behavior. Focused code-level tests are the narrowest proof that the boundary is explicit and enforceable.
- **Alternatives considered**:
- Browser or UI smoke coverage: rejected because the slice adds no new operator-facing surface.
- Manual review only: rejected because the feature exists specifically to remove dependence on reviewer memory.

View File

@ -0,0 +1,235 @@
# Feature Specification: Provider Boundary Hardening
**Feature Branch**: `237-provider-boundary-hardening`
**Created**: 2026-04-24
**Status**: Draft
**Input**: User description: "Provider Boundary Hardening"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: TenantPilot already has generic-looking provider seams, but shared contracts and orchestration paths can still absorb Microsoft Graph or Entra semantics as if they were platform-core truth.
- **Today's failure**: Contributors can extend provider-backed workflows by deepening Microsoft-shaped fields, defaults, and fallback behavior inside shared platform seams, which quietly turns future portability into cross-cutting rewrite work.
- **User-visible improvement**: Current Microsoft-backed workflows stay intact while the platform core becomes more predictable, reviewable, and resistant to accidental provider leakage in future work.
- **Smallest enterprise-capable version**: Classify the highest-risk shared seams as provider-owned or platform-core, harden the shared contracts around neutral concepts, and add focused review and test guardrails that block new platform-core Graph leakage.
- **Explicit non-goals**: No second-provider runtime, no AWS or GCP adapters, no broad provider marketplace or registry redesign, no speculative generic connector framework, and no repo-wide copy cleanup beyond seams that are genuinely platform-core.
- **Permanent complexity imported**: One explicit provider-boundary classification model for in-scope seams, one stricter neutral-contract discipline for platform-core paths, one bounded exception model for current-release Microsoft truth, and focused regression coverage for guardrails.
- **Why now**: The roadmap places this immediately after Canonical Control Catalog Foundation in the near-term Governance Platform anti-drift sequence, and Spec 236 is already created.
- **Why not local**: Local fixes inside one adapter or one page would leave other shared seams free to keep importing Microsoft semantics, so the product would continue drifting even while individual hotspots look cleaner.
- **Approval class**: Core Enterprise
- **Red flags triggered**: New abstraction risk and future-proofing risk. Defense: the first slice stays tightly scoped to already hot shared seams, preserves Microsoft-first product truth, and avoids speculative multi-provider runtime work.
- **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 new standalone route is required in the hardening slice.
- Existing provider-backed admin, tenant-context, and monitoring flows remain on their current surfaces and consume the hardened seams behind the scenes.
- **Data Ownership**:
- No new tenant-owned or workspace-owned business entity is introduced.
- The first slice hardens shared contracts, boundary classification, and provider-owned metadata handling around existing provider and governance workflows.
- **RBAC**:
- No new top-level capability is introduced.
- Existing authorization on provider-backed workflows continues to gate downstream behavior; this spec must not relax workspace or tenant isolation.
For canonical-view specs, the spec MUST define:
- Not applicable in this slice because no new canonical-view surface or cross-tenant filter behavior is introduced.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: provider-backed orchestration, shared contracts, monitoring-adjacent execution seams, governed-subject resolution boundaries
- **Systems touched**: provider gateways, provider client adapters, shared orchestration or registry seams, and guardrails that review new provider-backed code paths
- **Existing pattern(s) to extend**: current provider gateway and provider client adapter seam, existing provider dispatch and operation orchestration boundaries, current provider-boundary constitution rules
- **Shared contract / presenter / builder / renderer to reuse**: existing provider gateway and provider-dispatch contracts stay in place; the feature hardens which side of the boundary owns provider-specific semantics rather than introducing a parallel path
- **Why the existing shared path is sufficient or insufficient**: the shared seams are sufficient as extension points, but they are insufficiently explicit today about whether touched behavior is provider-owned or platform-core, so contributors can still deepen Microsoft coupling by default
- **Allowed deviation and why**: bounded current-release Microsoft exceptions are allowed only where the seam is explicitly classified as provider-owned or where the spec records a narrow provider-specific necessity
- **Consistency impact**: in-scope shared contracts must use one neutral platform vocabulary, and provider-specific descriptors, identifiers, payload assumptions, and fallback behavior must remain on the provider-owned side of the seam
- **Review focus**: reviewers should block any new shared-path change that exposes Graph- or Entra-shaped semantics as platform-core truth without an explicit boundary classification or approved exception
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: binary ownership with documented current-release exception metadata
- **Seams affected**: provider gateways, client adapters, orchestration entry points, registry or resolution seams that decide provider-backed behavior, and shared vocabulary used by those contracts
- **Authoritative first-slice seam inventory**:
- `provider.gateway_runtime` -> provider-owned -> `ProviderGateway.php`, `MicrosoftGraphOptionsResolver.php`
- `provider.identity_resolution` -> platform-core with documented exception metadata -> `ProviderIdentityResolution.php`, `ProviderIdentityResolver.php`, `PlatformProviderIdentityResolver.php`
- `provider.connection_resolution` -> platform-core with documented exception metadata -> `ProviderConnectionResolver.php`, `ProviderConnectionResolution.php`
- `provider.operation_registry` -> platform-core -> `ProviderOperationRegistry.php`
- `provider.operation_start_gate` -> platform-core -> `ProviderOperationStartGate.php`
- **Neutral platform terms preserved or introduced**: provider, provider connection, target scope, operation type, governed subject, provider-owned metadata, platform-core contract, unsupported combination
- **Provider-specific semantics retained and why**: Microsoft Graph payload shapes, Entra and tenant identifiers, Microsoft workload descriptors, and any provider-specific transport or error details remain provider-owned because current product truth is still Microsoft-first
- **Why this does not deepen provider coupling accidentally**: the first slice makes the boundary explicit, keeps seam ownership binary, and requires any retained provider-specific semantics to live either behind provider-owned contracts or as named exception metadata on a platform-core seam instead of becoming a third ownership class
- **Follow-up path**: Provider Identity & Target Scope Neutrality and Platform Vocabulary Boundary Enforcement for Governed Subject Keys build on this boundary hardening rather than redefining it
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
N/A - no operator-facing surface change is required in the hardening slice.
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: yes
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: yes - seam ownership and boundary-check result values are new; runtime `ProviderReasonCodes` expansion remains conditional on whether existing unsupported-path handling is insufficient
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: The product can still become more provider-shaped with each new shared-path change, which raises the long-term cost of governance features and increases the risk that platform-visible behavior is really Microsoft behavior with generic names.
- **Existing structure is insufficient because**: existing generic class names and seams do not by themselves prevent provider-specific identifiers, fallback semantics, or vocabulary from becoming platform-core truth.
- **Narrowest correct implementation**: classify only the highest-risk shared seams, keep current Microsoft behavior where it already belongs, harden neutral contracts at those seams, and add guardrails instead of broad speculative architecture.
- **Ownership cost**: maintainers must preserve the seam classifications, uphold the neutral-contract rules in review, and keep focused tests aligned as provider-backed workflows evolve.
- **Alternative intentionally rejected**: a full multi-provider abstraction rewrite was rejected because it imports speculative scope; pure local cleanup was rejected because it leaves the repo free to continue drifting at every other shared seam.
- **Release truth**: current-release truth with deliberate anti-drift preparation for later provider-boundary follow-through
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: the first slice is a contract-boundary hardening effort. Unit coverage proves seam classification and neutral-contract rules, while focused feature coverage proves that current Microsoft-backed workflows still behave correctly through the hardened seams.
- **New or expanded test families**: targeted provider-boundary guardrail tests only
- **Fixture / helper cost impact**: minimal; prefer existing provider fixtures and narrow seam-specific helpers over new broad provider test scaffolding
- **Heavy-family visibility / justification**: none; no browser or heavy-governance lane is justified for this slice
- **Special surface test profile**: N/A
- **Standard-native relief or required special coverage**: ordinary feature coverage only
- **Reviewer handoff**: reviewers should confirm that the tests prove boundary ownership and unsupported-path handling, not just class existence, and that no new heavy provider harness becomes the default path for ordinary seam checks
- **Budget / baseline / trend impact**: none expected
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderBoundaryClassificationTest.php tests/Unit/Providers/ProviderBoundaryGuardrailTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderBoundaryHardeningTest.php tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Classify Shared Seams Before Extending Them (Priority: P1)
As a maintainer extending a provider-backed workflow, I want to know whether a seam is provider-owned or platform-core before I change it so I do not accidentally encode Microsoft semantics into shared platform truth.
**Why this priority**: This is the smallest valuable slice. If the seam classification is explicit, later changes can stay bounded even before every follow-on cleanup is delivered.
**Independent Test**: Inspect a targeted shared seam, classify it, and prove that a contributor can determine where provider-specific semantics are allowed without guessing from class names alone.
**Acceptance Scenarios**:
1. **Given** a shared provider-backed seam is in scope for the hardening slice, **When** a maintainer reviews it, **Then** the seam is explicitly classified as provider-owned or platform-core.
2. **Given** a seam is classified as platform-core, **When** a change introduces Microsoft-specific identifiers or payload assumptions there, **Then** the change is rejected by the defined guardrails rather than silently accepted.
---
### User Story 2 - Keep Microsoft Truth Bounded Without Breaking Current Behavior (Priority: P1)
As a product maintainer, I want current Microsoft-backed workflows to continue working while Microsoft-specific semantics move or remain behind provider-owned boundaries so the anti-drift work does not regress the current release.
**Why this priority**: The hardening only matters if it is shippable without destabilizing the product's actual Microsoft-first workflows.
**Independent Test**: Exercise at least one hardened Microsoft-backed path and confirm the user-visible behavior stays the same while the shared seam no longer owns provider-specific truth.
**Acceptance Scenarios**:
1. **Given** a provider-owned adapter is responsible for Microsoft-specific request or response handling, **When** the shared platform seam calls it, **Then** the Microsoft behavior still completes correctly without moving Graph semantics into the platform-core contract.
2. **Given** a provider-specific concept is not supported at a shared seam, **When** the platform-core path encounters it, **Then** the result is an explicit unsupported outcome instead of an inherited Microsoft default.
---
### User Story 3 - Catch New Provider Leakage in Review and CI (Priority: P2)
As a reviewer, I want narrow tests and guardrails that catch new platform-core provider leakage so the boundary does not depend on tribal knowledge.
**Why this priority**: Without enforceable guardrails, the spec would describe a preferred architecture but not actually keep the repo from drifting back.
**Independent Test**: Introduce a representative provider-specific field or fallback into a platform-core seam and verify that the new guardrail coverage fails.
**Acceptance Scenarios**:
1. **Given** a new platform-core contract change introduces provider-specific vocabulary without an approved exception, **When** the targeted tests or review guardrails run, **Then** the change fails visibly.
2. **Given** a provider-owned seam intentionally keeps Microsoft-specific metadata, **When** the same guardrails run, **Then** the bounded provider-owned usage is allowed and documented instead of being treated as a false violation.
### Edge Cases
- One in-scope seam contains both neutral platform fields and provider-owned metadata and must separate them without inventing duplicate contracts.
- A historical shared path still uses Graph- or Entra-shaped field names even though the seam is now classified as platform-core.
- Only one provider exists in production today, so unsupported-path behavior must stay explicit without pretending a second provider runtime already exists.
- A shared orchestration path currently falls back to Microsoft-first behavior when no explicit provider handler is present.
- A provider-owned surface intentionally uses Microsoft vocabulary because the operator is configuring a Microsoft-specific capability, while the shared contract under it must remain neutral.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not add Microsoft Graph calls, destructive mutations, or new queued work. It hardens where existing provider-backed runtime behavior may encode provider-specific semantics into platform-core truth. If implementation touches existing writes or operations, that slice must keep current tenant isolation and existing observability intact.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature intentionally introduces one explicit boundary-classification model and one stricter neutral-contract discipline because generic class names alone have not prevented provider leakage. The first slice stays derived and review-oriented, avoids new persistence, and avoids speculative multi-provider abstractions.
**Constitution alignment (XCUT-001):** This feature is cross-cutting across provider gateways, orchestration, and shared contracts. It extends existing shared seams rather than creating parallel provider infrastructure, and reviewer focus stays on preventing new local Microsoft-shaped paths from appearing inside platform-core code.
**Constitution alignment (PROV-001):** Every in-scope seam must be classified as provider-owned or platform-core. Provider-specific semantics remain on provider-owned paths unless a narrow current-release exception is documented. Shared platform contracts preserve neutral platform vocabulary and explicit unsupported-path behavior.
**Constitution alignment (TEST-GOV-001):** Coverage stays in narrow unit and feature lanes. Tests must prove boundary ownership and unsupported-path semantics on the touched seams without introducing a broad provider integration harness.
**Constitution alignment (OPS-UX):** Not applicable in this slice because it does not create or redesign an `OperationRun`.
**Constitution alignment (RBAC-UX):** No authorization boundary changes are introduced. Existing authorization continues to protect provider-backed flows on their current surfaces.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
**Constitution alignment (BADGE-001):** Not applicable in this slice because no new status badge family is introduced.
**Constitution alignment (UI-FIL-001):** Not applicable in this slice because no Filament surface change is required.
**Constitution alignment (UI-NAMING-001):** If implementation touches any operator-visible provider descriptor as part of a hardened seam, Microsoft-specific wording must remain contextual to the provider-owned surface and must not redefine the shared platform contract.
**Constitution alignment (DECIDE-001):** Not applicable in this slice because no new or materially changed operator-facing decision surface is required.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Not applicable in this slice because no operator-facing surface is added or materially refactored.
**Constitution alignment (ACTSURF-001 - action hierarchy):** Not applicable.
**Constitution alignment (OPSURF-001):** Not applicable in this slice because there is no new operator-facing page contract.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature adds one interpretation layer for boundary ownership because direct reuse of current class names and runtime paths is insufficient to keep provider-specific truth out of platform-core seams. Tests must focus on boundary consequences rather than thin wrappers.
### Functional Requirements
- **FR-237-001 Boundary ownership classification**: The system MUST define one explicit boundary-ownership classification for every high-risk shared seam touched by this feature, identifying it as provider-owned or platform-core. Retained current-release exceptions MUST be recorded as seam metadata, not as a third ownership class.
- **FR-237-002 Neutral platform-core contracts**: Platform-core seams in scope MUST use neutral platform vocabulary and MUST NOT expose Microsoft- or Graph-specific identifiers, payload semantics, or fallback assumptions as their primary contract.
- **FR-237-003 Provider-owned metadata containment**: Microsoft-specific request, response, transport, verification, and error semantics MUST remain behind provider-owned adapters or services.
- **FR-237-004 Hardened shared seam coverage**: The first slice MUST harden at least the current provider gateway seam and at least one shared orchestration or registry seam where provider leakage currently risks becoming platform-core truth.
- **FR-237-005 Explicit unsupported behavior**: If a platform-core seam encounters a provider-specific behavior with no explicit provider-owned handler, the outcome MUST be explicit unsupported behavior rather than inherited Microsoft default behavior.
- **FR-237-006 Current-release behavior preservation**: The hardened seams MUST preserve current Microsoft-backed product behavior for already supported flows.
- **FR-237-007 Exception discipline**: Any retained provider-specific semantics at a shared seam MUST be documented as a narrow current-release exception and MUST NOT become the default meaning of the platform-core contract.
- **FR-237-008 Review and test guardrails**: The repo MUST provide focused review or test guardrails that fail when new platform-core provider leakage is introduced on an in-scope seam.
- **FR-237-009 Provider-owned allowance**: The same guardrails MUST allow provider-specific metadata and logic on seams explicitly classified as provider-owned.
- **FR-237-010 No speculative multi-provider runtime**: The first slice MUST NOT introduce second-provider execution paths, provider marketplace abstractions, or generic connector runtime machinery.
- **FR-237-011 Shared vocabulary continuity**: Follow-on provider-boundary work for identity, target scope, or governed-subject vocabulary MUST be able to build on the same boundary classifications instead of redefining them locally.
- **FR-237-012 Explicit shared orchestration binding**: Shared orchestration paths touched by this feature MUST require an explicit provider binding instead of inferring `microsoft` as an implicit default when the platform-core path resolves an operation.
### Key Entities *(include if feature involves data)*
- **Boundary Ownership Classification**: The explicit determination that a touched seam is either provider-owned or platform-core, with any narrow current-release exception recorded as metadata on that seam.
- **Platform-Core Contract**: A shared contract that expresses provider-neutral platform concepts and delegates provider-specific behavior to provider-owned seams.
- **Provider-Owned Adapter**: The seam that translates Microsoft-specific identifiers, payloads, verification details, and errors into or out of platform-core contracts without redefining platform truth.
- **Boundary Guardrail Result**: The focused review or test outcome that confirms whether a changed seam respects the declared ownership and neutral-contract rules.
## Assumptions
- Current-release product truth remains Microsoft-first, but Microsoft semantics must stay bounded where the seam is platform-core.
- Existing provider gateway and orchestration hotspots are sufficient to prove the first slice without introducing a new provider runtime.
- Identity, target-scope, and governed-subject follow-through specs will consume this boundary decision instead of reopening it from scratch.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-237-001**: 100% of high-risk shared seams touched in the first slice are explicitly classified as provider-owned or platform-core before implementation is considered complete.
- **SC-237-002**: 100% of platform-core seams hardened by this spec avoid Microsoft- or Graph-specific primary contract fields or fallback semantics.
- **SC-237-003**: At least one already supported Microsoft-backed workflow continues to pass through the hardened seams without user-visible regression.
- **SC-237-004**: In all covered unsupported-path scenarios for the hardened seams, the outcome is explicit unsupported behavior rather than inherited Microsoft default behavior.

View File

@ -0,0 +1,226 @@
---
description: "Task list for Provider Boundary Hardening"
---
# Tasks: Provider Boundary Hardening
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/237-provider-boundary-hardening/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/237-provider-boundary-hardening/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/237-provider-boundary-hardening/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/237-provider-boundary-hardening/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/237-provider-boundary-hardening/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/237-provider-boundary-hardening/contracts/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/237-provider-boundary-hardening/quickstart.md`
**Tests**: REQUIRED (Pest) for runtime behavior changes; keep proof in the narrow `Unit` and `Feature` lanes named in the plan
**Operations**: No new `OperationRun` type or new monitoring surface is introduced; preserve current `ProviderOperationStartGate` behavior while making provider binding explicit
**RBAC**: No authorization plane changes are planned; preserve existing tenant and workspace enforcement on touched provider-backed flows
**Provider Boundary**: Every touched seam must be classified as `provider_owned` or `platform_core`, and any retained Microsoft-shaped behavior must remain explicitly bounded as documented seam metadata rather than a third ownership class
**Organization**: Tasks are grouped by user story so the seam-classification slice, runtime hardening slice, and guardrail slice can be delivered and validated incrementally.
## Test Governance Checklist
- [X] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [X] New or changed tests stay in the existing provider test families; no browser or heavy-governance lane is added.
- [X] Shared helpers, factories, fixtures, and provider context defaults must stay cheap by default; do not introduce a default multi-provider harness.
- [X] Planned validation commands must cover the boundary catalog, runtime preservation, unsupported-path handling, and registry split without widening scope.
- [X] Surface test profile remains `N/A` because this slice adds no new operator-facing screen.
- [X] Any remaining Microsoft-first hotspot must resolve as `document-in-feature` or `follow-up-spec`, not as silent platform-core truth.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the baseline hotspots, current proof lanes, and existing guard patterns before implementation starts.
- [X] T001 Review the current hotspot seams in `apps/platform/app/Services/Providers/ProviderGateway.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, and `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
- [X] T002 Run the existing provider baseline tests for `apps/platform/tests/Unit/Providers/ProviderGatewayTest.php` and `apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php`
- [X] T003 [P] Review the existing boundary-guard patterns in `apps/platform/tests/Feature/Guards/NoLegacyTenantGraphOptionsTest.php` and `apps/platform/tests/Feature/Guards/NoLegacyTenantProviderFallbackTest.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared boundary catalog primitives that every user story depends on.
**⚠️ CRITICAL**: No user story work should start until these tasks are complete.
- [X] T004 Create the seam ownership catalog scaffold in `apps/platform/config/provider_boundaries.php`
- [X] T005 [P] Add the ownership enum in `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryOwner.php`
- [X] T006 [P] Add the seam descriptor value object in `apps/platform/app/Support/Providers/Boundary/ProviderBoundarySeam.php`
- [X] T007 Implement deterministic catalog lookup in `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`
**Checkpoint**: Shared boundary primitives exist; user story work can now build on one explicit source of truth.
---
## Phase 3: User Story 1 - Classify Shared Seams Before Extending Them (Priority: P1) 🎯 MVP
**Goal**: Make the first high-risk shared provider seams explicitly classifiable so contributors can tell where Microsoft-specific semantics are allowed.
**Independent Test**: Run `tests/Unit/Providers/ProviderBoundaryClassificationTest.php` and verify the authoritative seam keys `provider.gateway_runtime`, `provider.identity_resolution`, `provider.connection_resolution`, `provider.operation_registry`, and `provider.operation_start_gate` resolve to the intended owner classification and exception metadata.
### Tests for User Story 1
- [X] T008 [P] [US1] Add seam classification coverage in `apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php`
### Implementation for User Story 1
- [X] T009 [US1] Populate the authoritative first-slice seam entries `provider.gateway_runtime`, `provider.identity_resolution`, `provider.connection_resolution`, `provider.operation_registry`, and `provider.operation_start_gate` in `apps/platform/config/provider_boundaries.php`
- [X] T010 [US1] Implement seam hydration and ownership assertions in `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`
- [X] T011 [US1] Encode neutral terms, retained provider semantics, and follow-up actions in `apps/platform/config/provider_boundaries.php` and `apps/platform/app/Support/Providers/Boundary/ProviderBoundarySeam.php`
- [X] T012 [US1] Align the seam catalog shape with `specs/237-provider-boundary-hardening/contracts/provider-boundary-hardening.logical.openapi.yaml` through `apps/platform/config/provider_boundaries.php` and `apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php`
- [X] T013 [US1] Run the story proof lane for `apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php`
**Checkpoint**: The seam catalog is explicit, deterministic, and independently testable.
---
## Phase 4: User Story 2 - Keep Microsoft Truth Bounded Without Breaking Current Behavior (Priority: P1)
**Goal**: Move Graph-shaped runtime behavior behind provider-owned seams while preserving current Microsoft-backed flows and making unsupported paths explicit.
**Independent Test**: Run the runtime regression and unsupported-path tests to confirm current Microsoft behavior still succeeds and platform-core seams no longer own Graph option shaping.
### Tests for User Story 2
- [X] T014 [P] [US2] Extend provider runtime regression coverage in `apps/platform/tests/Unit/Providers/ProviderGatewayTest.php` and `apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php`
- [X] T015 [P] [US2] Add Microsoft runtime preservation coverage in `apps/platform/tests/Feature/Providers/ProviderBoundaryHardeningTest.php`
- [X] T016 [P] [US2] Add explicit unsupported-path coverage in `apps/platform/tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php`
### Implementation for User Story 2
- [X] T017 [US2] Remove Graph request-option shaping from `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`
- [X] T018 [US2] Move Graph option assembly into `apps/platform/app/Services/Providers/ProviderGateway.php` and `apps/platform/app/Services/Providers/MicrosoftGraphOptionsResolver.php`
- [X] T019 [US2] Preserve bounded Microsoft-first identity exceptions in `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, and `apps/platform/app/Services/Providers/ProviderConnectionResolution.php`
- [X] T020 [US2] Make the current-release exception metadata explicit in `apps/platform/config/provider_boundaries.php` and `apps/platform/app/Support/Providers/Boundary/ProviderBoundarySeam.php`
- [X] T021 [US2] Run the story proof lane for `apps/platform/tests/Unit/Providers/ProviderGatewayTest.php`, `apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php`, `apps/platform/tests/Feature/Providers/ProviderBoundaryHardeningTest.php`, and `apps/platform/tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php`
**Checkpoint**: Current Microsoft-backed runtime behavior still works, and the shared identity path no longer emits Graph-shaped platform-core truth.
---
## Phase 5: User Story 3 - Catch New Provider Leakage in Review and CI (Priority: P2)
**Goal**: Split shared operation definition from provider binding and add focused guardrails that fail when provider-specific semantics leak back into platform-core seams.
**Independent Test**: Run the guardrail and start-gate tests to confirm platform-core/provider-owned boundaries are enforced and unsupported bindings fail explicitly.
### Tests for User Story 3
- [X] T022 [P] [US3] Add a CI boundary leak guard in `apps/platform/tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
- [X] T023 [P] [US3] Add boundary leak and exception guard coverage in `apps/platform/tests/Unit/Providers/ProviderBoundaryGuardrailTest.php`
- [X] T024 [P] [US3] Extend provider binding and unsupported-start coverage in `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`
### Implementation for User Story 3
- [X] T025 [US3] Split platform-core operation definition from provider binding in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`
- [X] T026 [US3] Consume explicit bindings and unsupported outcomes in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
- [X] T027 [US3] Re-use existing unsupported-path reason handling, or add one narrow boundary violation and unsupported-binding reason in `apps/platform/app/Support/Providers/ProviderReasonCodes.php` only if the explicit shared-boundary outcome cannot be expressed without it
- [X] T028 [US3] Record binding status, handler notes, and exception notes for first-slice operations in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` and `apps/platform/config/provider_boundaries.php`
- [X] T029 [US3] Align the operation definition and binding split with `specs/237-provider-boundary-hardening/contracts/provider-boundary-hardening.logical.openapi.yaml` through `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` and `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`
- [X] T030 [US3] Run the story proof lane for `apps/platform/tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`, `apps/platform/tests/Unit/Providers/ProviderBoundaryGuardrailTest.php`, `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`, and `apps/platform/tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php`
**Checkpoint**: Provider binding is explicit, guardrails fail on new leakage, and shared orchestration no longer silently defaults to Microsoft-first behavior.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finalize validation, formatting, and guardrail close-out across the full slice.
- [X] T031 [P] Refresh the implementation notes, validation commands, and bounded follow-up status in `specs/237-provider-boundary-hardening/quickstart.md`
- [X] T032 Run formatting for `apps/platform/app/Support/Providers/Boundary/`, `apps/platform/app/Services/Providers/`, `apps/platform/tests/Unit/Providers/`, and `apps/platform/tests/Feature/Guards/` with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- [X] T033 Run the final narrow validation lane for `apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php`, `apps/platform/tests/Unit/Providers/ProviderBoundaryGuardrailTest.php`, `apps/platform/tests/Feature/Providers/ProviderBoundaryHardeningTest.php`, `apps/platform/tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php`, and `apps/platform/tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php`
- [X] T034 Record the guardrail close-out, `document-in-feature` decision, and deferred identity-neutrality follow-up in `specs/237-provider-boundary-hardening/quickstart.md`
---
## Dependencies & Execution Order
### User Story Dependency Graph
```text
Phase 1 (Setup)
Phase 2 (Foundation: seam catalog primitives)
US1 (Seam classification)
US2 (Runtime hardening and explicit exceptions)
US3 (Registry split and guardrails)
Phase 6 (Polish)
```
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; starts immediately.
- **Foundational (Phase 2)**: Depends on Setup; blocks all user story work.
- **User Story 1 (Phase 3)**: Depends on the boundary catalog primitives from Phase 2.
- **User Story 2 (Phase 4)**: Depends on US1 because runtime hardening consumes the explicit seam ownership catalog.
- **User Story 3 (Phase 5)**: Depends on US1 and US2 because registry guardrails must reflect the classified seams and the hardened runtime path.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### Parallel Opportunities
- Phase 2 tasks `T005` and `T006` can run in parallel because they touch different support classes.
- US1 tasks `T008` and `T010` can run in parallel after the catalog scaffold exists because the test file and catalog service are separate files.
- US2 tasks `T014`, `T015`, and `T016` can run in parallel because they cover different test files.
- US3 tasks `T022`, `T023`, and `T024` can run in parallel because they extend separate guard and unit test files.
- Phase 6 tasks `T031` and `T032` can run in parallel because they touch documentation and formatting independently.
---
## Parallel Example: User Story 1
```bash
Task: "Add seam classification coverage in apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php"
Task: "Implement seam hydration and ownership assertions in apps/platform/app/Support/Providers/Boundary/ProviderBoundaryCatalog.php"
```
## Parallel Example: User Story 2
```bash
Task: "Extend provider runtime regression coverage in apps/platform/tests/Unit/Providers/ProviderGatewayTest.php and apps/platform/tests/Unit/Providers/ProviderIdentityResolverTest.php"
Task: "Add Microsoft runtime preservation coverage in apps/platform/tests/Feature/Providers/ProviderBoundaryHardeningTest.php"
Task: "Add explicit unsupported-path coverage in apps/platform/tests/Feature/Providers/UnsupportedProviderBoundaryPathTest.php"
```
## Parallel Example: User Story 3
```bash
Task: "Add boundary leak and exception guard coverage in apps/platform/tests/Unit/Providers/ProviderBoundaryGuardrailTest.php"
Task: "Extend provider binding and unsupported-start coverage in apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php"
Task: "Add a CI boundary leak guard in apps/platform/tests/Feature/Guards/ProviderBoundaryPlatformCoreGuardTest.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational seam catalog primitives.
3. Complete Phase 3: User Story 1.
4. Stop and validate `apps/platform/tests/Unit/Providers/ProviderBoundaryClassificationTest.php`.
5. Review whether the first-slice seam catalog is explicit enough before widening into runtime cleanup.
### Incremental Delivery
1. Setup + Foundation establish one explicit boundary source of truth.
2. US1 delivers seam classification and explicit exception metadata documentation.
3. US2 hardens the shared runtime path while preserving current Microsoft-backed behavior.
4. US3 makes provider binding explicit and adds CI-proof guardrails.
5. Polish closes formatting, validation, and the documented follow-up boundary.
### Suggested MVP Scope
The narrowest shippable increment is Phase 1, Phase 2, and Phase 3 only.
---
## Notes
- `[P]` tasks touch different files and can be worked independently.
- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`.
- Keep provider-specific semantics bounded to provider-owned seams; do not introduce a second-provider runtime while completing these tasks.
- Use the Sail-prefixed validation commands from `specs/237-provider-boundary-hardening/quickstart.md` when executing the proof lanes.

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Provider Identity & Target Scope Neutrality
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-04-24
**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
- The spec stays bounded to provider connection identity and target-scope semantics on existing shared surfaces.
- Broader governed-subject and compare-boundary work remains an explicit follow-up, not hidden scope inside this draft.

View File

@ -0,0 +1,361 @@
openapi: 3.1.0
info:
title: Provider Identity & Target Scope Neutrality Logical Contract
version: 0.1.0
description: |
Logical internal contract for the provider connection target-scope neutrality
slice. It describes the normalized shared target-scope descriptor, the
operator-facing surface summary, and the bounded neutrality guard result.
It is not a commitment to expose public HTTP routes.
Review stop: shared provider connection surfaces must use neutral target
scope truth by default, carry provider-owned Microsoft identity only as
contextual metadata, and resolve remaining provider-boundary drift through
document-in-feature or follow-up-spec disposition instead of silent shared
platform truth.
paths:
/logical/provider-connections/{connectionId}/target-scope:
get:
summary: Read the normalized target-scope descriptor for an existing provider connection
operationId: getProviderConnectionTargetScope
parameters:
- name: connectionId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Normalized target-scope descriptor
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
/logical/provider-connections/target-scope/normalize:
post:
summary: Normalize create or edit input into shared target-scope truth plus contextual provider metadata
operationId: normalizeProviderConnectionTargetScope
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionTargetScopeInput'
responses:
'200':
description: Normalized descriptor and optional shared-surface preview
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationSuccess'
'422':
description: Unsupported provider-scope combination or missing provider context
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionTargetScopeNormalizationFailure'
/logical/provider-connections/{connectionId}/surface-summary:
get:
summary: Read the default-visible operator-facing summary for a shared provider connection surface
operationId: getProviderConnectionSurfaceSummary
parameters:
- name: connectionId
in: path
required: true
schema:
type: integer
responses:
'200':
description: Shared-surface summary
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionSurfaceSummary'
/logical/provider-connections/neutrality/evaluate:
post:
summary: Evaluate whether a touched shared provider-connection path preserves neutral target-scope truth
operationId: evaluateProviderConnectionNeutrality
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionNeutralityEvaluationRequest'
responses:
'200':
description: Neutrality evaluation result
content:
application/json:
schema:
$ref: '#/components/schemas/ProviderConnectionNeutralityCheckResult'
components:
schemas:
ProviderConnectionSupportedScopeKind:
type: string
enum:
- tenant
ProviderIdentityContextVisibility:
type: string
enum:
- contextual_only
- audit_only
- troubleshooting_only
ProviderIdentityContextMetadata:
type: object
properties:
provider:
type: string
detail_key:
type: string
detail_label:
type: string
detail_value:
type: string
visibility:
$ref: '#/components/schemas/ProviderIdentityContextVisibility'
required:
- provider
- detail_key
- detail_label
- detail_value
- visibility
ProviderConnectionTargetScopeDescriptor:
type: object
properties:
provider:
type: string
scope_kind:
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
scope_identifier:
type: string
scope_display_name:
type: string
shared_label:
type: string
shared_help_text:
type: string
required:
- provider
- scope_kind
- scope_identifier
- scope_display_name
- shared_label
- shared_help_text
ProviderConnectionTargetScopeInput:
type: object
properties:
provider:
type: string
scope_kind:
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
scope_identifier:
type: string
scope_display_name:
type: string
provider_specific_identity:
type: object
additionalProperties:
type: string
required:
- provider
- scope_kind
- scope_identifier
- scope_display_name
ProviderConnectionTargetScopeNormalizationSuccess:
type: object
properties:
status:
type: string
enum:
- normalized
provider:
type: string
scope_kind:
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
target_scope:
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
contextual_identity_details:
type: array
items:
$ref: '#/components/schemas/ProviderIdentityContextMetadata'
failure_code:
type: string
enum:
- none
message:
type: string
preview_summary:
oneOf:
- $ref: '#/components/schemas/ProviderConnectionSurfaceSummary'
- type: 'null'
required:
- status
- provider
- scope_kind
- target_scope
- contextual_identity_details
- failure_code
- message
- preview_summary
ProviderConnectionTargetScopeNormalizationFailure:
type: object
properties:
status:
type: string
enum:
- blocked
provider:
type: string
scope_kind:
$ref: '#/components/schemas/ProviderConnectionSupportedScopeKind'
failure_code:
type: string
enum:
- unsupported_provider_scope_combination
- missing_provider_context
message:
type: string
required:
- status
- provider
- scope_kind
- failure_code
- message
ProviderConnectionSurfaceSummary:
type: object
properties:
provider:
type: string
target_scope:
$ref: '#/components/schemas/ProviderConnectionTargetScopeDescriptor'
consent_state:
type: string
verification_state:
type: string
readiness_summary:
type: string
contextual_identity_details:
type: array
items:
$ref: '#/components/schemas/ProviderIdentityContextMetadata'
required:
- provider
- target_scope
- consent_state
- verification_state
- readiness_summary
- contextual_identity_details
ProviderConnectionNeutralityEvaluationRequest:
type: object
properties:
surface_key:
type: string
path:
type: string
surface_ownership:
type: string
enum:
- platform_core
- provider_owned
default_labels:
type: array
items:
type: string
required_fields:
type: array
items:
type: string
filter_labels:
type: array
items:
type: string
validation_messages:
type: array
items:
type: string
helper_copy:
type: array
items:
type: string
audit_prose:
type: array
items:
type: string
allowed_exception_classes:
type: array
items:
type: string
provider:
type: string
provider_owned_context:
type: array
items:
type: string
required:
- surface_key
- path
- surface_ownership
- default_labels
- required_fields
- filter_labels
- validation_messages
- helper_copy
- audit_prose
- allowed_exception_classes
- provider
- provider_owned_context
ProviderConnectionNeutralityCheckResult:
type: object
description: |
Guard result for touched shared provider connection surfaces. A blocked
or review_required result must name whether the issue is a default
label, filter, required field, validation message, helper copy, audit
prose, or missing target-scope descriptor, and must route the close-out
through document-in-feature or follow-up-spec.
properties:
status:
type: string
enum:
- allowed
- review_required
- blocked
surface_key:
type: string
path:
type: string
surface_ownership:
type: string
enum:
- platform_core
- provider_owned
allowed_exception_classes:
type: array
items:
type: string
violation_code:
type: string
enum:
- none
- provider_specific_default_label
- provider_specific_default_filter
- provider_specific_required_field
- provider_specific_validation_message
- provider_specific_default_helper_copy
- provider_specific_default_audit_prose
- missing_target_scope_descriptor
message:
type: string
suggested_follow_up:
type: string
enum:
- none
- document-in-feature
- follow-up-spec
required:
- status
- surface_key
- path
- surface_ownership
- allowed_exception_classes
- violation_code
- message
- suggested_follow_up

View File

@ -0,0 +1,140 @@
# Data Model: Provider Identity & Target Scope Neutrality
## Overview
This slice introduces one shared target-scope descriptor, one provider-owned contextual identity metadata shape, one operator-facing connection summary, and one narrow neutrality guard result. No new database persistence is introduced.
## Entity: ProviderConnectionTargetScopeDescriptor
- **Purpose**: Express the neutral platform meaning of what a provider connection points to without carrying provider-specific identity as part of the descriptor itself.
- **Identity**:
- `provider_connection_id` for existing records
- draft or create-flow input tuple `(provider, scope_kind, scope_identifier, scope_display_name)` before persistence
- **Core fields**:
- `provider`
- `scope_kind`
- `scope_identifier`
- `scope_display_name`
- `shared_label`
- `shared_help_text`
- **Validation rules**:
- The descriptor must remain renderable without provider-specific labels.
- In this current-release slice, `scope_kind` is tenant-only even though the neutral field remains generic for future provider-boundary-safe extension.
- `scope_kind`, `scope_identifier`, and `scope_display_name` must be sufficient to describe the neutral target-scope meaning on shared surfaces.
- `scope_identifier` and `scope_display_name` must be usable on shared surfaces without relying on Microsoft directory vocabulary.
- Provider-owned identity details must live beside the descriptor in contextual metadata or summary shapes, not inside the descriptor itself.
## Entity: ProviderConnectionTargetScopeNormalizationResult
- **Purpose**: Represent the deterministic result of normalizing create or edit input before persistence, including explicit blocked outcomes for unsupported combinations or missing provider context.
- **Fields**:
- `status`
- `provider`
- `scope_kind`
- `target_scope` when `status = normalized`
- `contextual_identity_details[]` when `status = normalized`
- `preview_summary` when a shared-surface preview can be derived without assuming persisted runtime state
- `failure_code`
- `message`
- **Status values**:
- `normalized`
- `blocked`
- **Failure code values**:
- `none`
- `unsupported_provider_scope_combination`
- `missing_provider_context`
- **Validation rules**:
- `normalized` results must carry `provider`, `scope_kind`, `target_scope`, `contextual_identity_details[]`, `failure_code = none`, and a human-readable `message`; they may include `preview_summary` only when consent and verification state can be derived without assuming persisted runtime state.
- `blocked` results must carry `provider`, `scope_kind`, `failure_code`, and `message` and must not pretend readiness or persisted summary truth exists.
- The normalization result must preserve the distinction between neutral target-scope truth and provider-owned contextual identity details.
## Entity: ProviderIdentityContextMetadata
- **Purpose**: Carry provider-owned identity details that remain necessary for Microsoft consent, verification, troubleshooting, or audit drill-in.
- **Fields**:
- `provider`
- `detail_key`
- `detail_label`
- `detail_value`
- `visibility`
- **Visibility values**:
- `contextual_only`
- `audit_only`
- `troubleshooting_only`
- **Validation rules**:
- Context metadata must never replace the shared target-scope descriptor on generic provider surfaces.
- Microsoft-only labels such as `Entra tenant ID` remain allowed only when `provider = microsoft` and visibility is contextual.
## Entity: ProviderConnectionSurfaceSummary
- **Purpose**: Define the default-visible operator-facing summary for shared provider connection surfaces.
- **Fields**:
- `provider`
- `target_scope`
- `consent_state`
- `verification_state`
- `readiness_summary`
- `contextual_identity_details[]`
- **Validation rules**:
- `provider`, `target_scope`, `consent_state`, and `verification_state` must all be visible without opening diagnostics.
- `contextual_identity_details[]` must remain secondary to the target-scope summary.
- Shared surface summaries must not collapse consent and verification into one ambiguous state.
## Entity: ProviderConnectionNeutralityCheckResult
- **Purpose**: Deterministic result shape used by guard tests and review checks.
- **Fields**:
- `status`
- `surface_key`
- `path`
- `surface_ownership`
- `allowed_exception_classes[]`
- `violation_code`
- `message`
- `suggested_follow_up`
- **Status values**:
- `allowed`
- `review_required`
- `blocked`
- **Violation code examples**:
- `provider_specific_default_label`
- `provider_specific_default_filter`
- `provider_specific_required_field`
- `provider_specific_validation_message`
- `provider_specific_default_helper_copy`
- `provider_specific_default_audit_prose`
- `missing_target_scope_descriptor`
- **Surface ownership values**:
- `platform_core`
- `provider_owned`
- **Validation rules**:
- `allowed` means the shared surface uses neutral target-scope truth by default.
- `review_required` means the path contains documented provider-owned contextual detail or an allowed exception class.
- `blocked` means a shared surface reintroduced provider-specific default truth.
## Relationships
- One `ProviderConnectionSurfaceSummary` consumes exactly one `ProviderConnectionTargetScopeDescriptor` and may carry zero or more `ProviderIdentityContextMetadata` entries beside it.
- One `ProviderConnectionTargetScopeNormalizationResult` may carry zero or more `ProviderIdentityContextMetadata` entries beside exactly one normalized target-scope descriptor.
- One `ProviderConnectionNeutralityCheckResult` references exactly one touched surface or helper path.
## Lifecycle
### Target-scope descriptor lifecycle
- `draft_input`: create or edit flow has neutral shared scope data but is not yet persisted.
- `persisted_shared_truth`: existing `provider_connections` row has a neutral target-scope descriptor available for shared surfaces.
- `context_enriched`: provider-owned contextual details are attached for Microsoft consent, verification, or audit drill-in.
### Neutrality check lifecycle
- `allowed`: shared surface is neutral by default.
- `review_required`: shared surface stays neutral but exposes documented provider-owned contextual detail.
- `blocked`: shared surface or helper reintroduced provider-specific default truth.
## Rollout Model
- No new database table or column is planned for this slice.
- The descriptor layer is derived from existing provider connection truth and existing provider-owned identity details.
- Provider connection list, detail, create, edit, onboarding provider setup, and audit wording adopt the new descriptor first.
- Broader provider identity migration and compare-boundary work remain out of scope for this feature.

View File

@ -0,0 +1,264 @@
# Implementation Plan: Provider Identity & Target Scope Neutrality
**Branch**: `238-provider-identity-target-scope` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/238-provider-identity-target-scope/spec.md`
**Note**: This plan keeps the slice intentionally narrow. It introduces one small shared target-scope descriptor layer over the existing provider connection and identity-resolution path, rewrites Microsoft-shaped default labels and summaries only on the in-scope shared surfaces, preserves Entra-specific identity details as contextual provider-owned metadata, and adds focused guardrails so shared provider/platform seams do not regress into Microsoft-first default truth.
## Summary
Add a config-free, in-process target-scope descriptor layer that sits between existing provider connection truth and operator-facing shared surfaces. The implementation will harden two concrete hotspots already visible in code: first, the shared resolution objects (`ProviderIdentityResolution` and adjacent provider-connection resolution paths) still expose Microsoft-shaped semantics such as `tenantContext`, `authorityTenant`, and `entra_tenant_id` as if they were default shared truth; second, `ProviderConnectionResource` uses `Entra tenant ID` as a required shared form field and as the default list/detail summary. The plan narrows the shared contract to neutral provider and target-scope concepts, keeps Microsoft tenant and directory identity available only as provider-owned contextual metadata, extends the same neutral semantics into onboarding-adjacent setup, aligns shared audit wording, and proves the result with focused unit, feature, Filament, onboarding, and guard coverage without adding a new provider runtime, new persistence, or a broader schema rewrite.
## Technical Context
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
**Primary Dependencies**: `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
**Storage**: Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned
**Testing**: Pest v4 unit and focused feature tests through Laravel Sail
**Validation Lanes**: `fast-feedback`, `confidence`
**Target Platform**: Laravel admin web application rendered through Filament on the existing workspace and tenant admin surfaces
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
**Performance Goals**: Keep target-scope resolution deterministic and in-process, add no new outbound call before existing consent or verification paths, and preserve current Microsoft-backed runtime performance on supported flows
**Constraints**: No new provider runtime, no provider marketplace, no new persistence, no broad credential-model redesign, no full rewrite of Spec 137 scope, no new operator-facing shell, and no new Microsoft-shaped default labels on shared surfaces
**Scale/Scope**: One shared target-scope descriptor layer, one bounded contextual-metadata path, three operator-facing surfaces, and focused unit plus feature guard coverage
## Filament v5 Implementation Contract
- **Livewire v4.0+ compliance**: Preserved. The feature changes existing Filament resources, shared support code, and tests only, with no legacy Livewire APIs.
- **Provider registration location**: Unchanged. Panel providers remain registered in `apps/platform/bootstrap/providers.php`.
- **Global search coverage**: No new resource or page is added and no existing global-search posture is widened. Provider connection global-search behavior remains unchanged in this slice.
- **Destructive actions**: No new destructive action is added. Existing security-sensitive provider connection mutations continue to require confirmation and authorization on their current surfaces.
- **Asset strategy**: No new assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` only when registered Filament assets change.
- **Testing plan**: Prove the slice with one shared descriptor unit seam, focused provider connection and onboarding feature coverage, existing audit and UI enforcement coverage extensions, and one bounded neutrality guard test.
## UI / Surface Guardrail Plan
- **Guardrail scope**: changed surfaces across provider connection list and detail, provider connection create and edit, and the tenant onboarding provider setup step
- **Native vs custom classification summary**: native Filament plus shared support helpers
- **Shared-family relevance**: shared provider connection family, onboarding provider setup family, shared audit wording
- **State layers in scope**: `page`, `detail`, `wizard-step`
- **Handling modes by drift class or surface**: `review-mandatory`
- **Repository-signal treatment**: `review-mandatory`
- **Special surface test profiles**: `standard-native-filament`
- **Required tests or manual smoke**: `functional-core`, `state-contract`, `manual-smoke`
- **Exception path and spread control**: one named Microsoft contextual-identity boundary for tenant or directory identifiers, consent wording, and verification detail that remain provider-owned instead of shared default truth
- **Active feature PR close-out entry**: `Guardrail`
## Shared Pattern & System Fit
- **Cross-cutting feature marker**: yes
- **Systems touched**: `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, provider connection resolution and mutation services, provider identity resolution, badge and summary rendering, and provider-connection audit wording
- **Shared abstractions reused**: existing provider connection resource and detail path, existing identity and connection resolution services, existing badge renderer domains, existing provider connection audit flows
- **New abstraction introduced? why?**: yes. One small target-scope descriptor and one small surface-summary mapping layer are needed because multiple real surfaces currently duplicate or embed Microsoft-shaped default meaning.
- **Why the existing abstraction was sufficient or insufficient**: the current shared path is sufficient for authorization, persistence, consent, and verification routing, but it is insufficient because the same path uses Microsoft-specific field names and summary language as the default shared contract.
- **Bounded deviation / spread control**: Microsoft tenant, directory, and consent-specific details remain allowed only inside explicitly provider-owned contextual sections, helper copy, and audit detail for the Microsoft provider
## OperationRun UX Impact
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: `N/A`
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
- **Shared provider/platform boundary touched?**: yes
- **Provider-owned seams**: Microsoft-specific consent and verification descriptors, contextual Entra tenant or directory identifiers, platform-app and authority details resolved by existing Microsoft identity services
- **Platform-core seams**: shared provider connection form labels, list and detail summaries, target-scope descriptor layer, shared validation shape, onboarding provider setup summary, audit wording for shared provider connection mutations
- **Neutral platform terms / contracts preserved**: provider, provider connection, target scope, scope identifier, scope display name, consent state, verification state, readiness summary
- **Retained provider-specific semantics and why**: `entra_tenant_id`, `authorityTenant`, `redirectUri`, and Microsoft-specific consent or verification wording remain because current-release truth is still Microsoft-first and operators still need these values on Microsoft-only paths
- **Bounded extraction or follow-up path**: broader governed-subject and compare-boundary work remains `follow-up-spec`; this feature resolves the provider connection identity and target-scope hotspot itself
## Constitution Check
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with one small descriptor layer, no new persistence, and no new provider runtime.*
| Gate | Status | Plan Notes |
|------|--------|------------|
| Inventory-first / read-write separation | PASS | The slice changes shared provider connection semantics and existing write-surface wording only. No new snapshot or operational write class is introduced. |
| Single Graph contract path / no inline remote work | PASS | Existing consent and verification flows keep their current provider-owned Graph path. The feature adds no new Graph call and no inline remote work on shared surfaces. |
| RBAC, workspace isolation, tenant isolation | PASS | Existing provider connection and onboarding surfaces keep current workspace and tenant guards. Non-members remain 404 and members missing capability remain 403. |
| Run observability / Ops-UX lifecycle | PASS | No new `OperationRun` type or UX behavior is introduced. Existing health-check or verification runs keep their current service-owned lifecycle. |
| Shared pattern first | PASS | The implementation reuses existing provider connection surfaces and resolution services instead of creating a new provider management stack. |
| Proportionality / no premature abstraction | PASS | One small target-scope descriptor layer is justified by multiple real surfaces and shared audits already depending on the same semantics. No marketplace or second-provider framework is introduced. |
| Persisted truth / behavioral state | PASS | No new table, persisted entity, or lifecycle family is added. Consent and verification remain the existing state dimensions. |
| Provider boundary | PASS | Shared target-scope truth is kept platform-core while Microsoft-specific identity remains provider-owned contextual metadata. |
| Filament v5 / Livewire v4 contract | PASS | The slice uses native Filament forms, tables, infolists, and existing shared primitives only. |
| Test governance | PASS | Coverage stays in focused unit and feature lanes with no browser or heavy-governance expansion. |
## Test Governance Check
- **Test purpose / classification by changed surface**: `Unit` for the descriptor and neutral-summary mapping seam; `Feature` for provider connection list/detail/create/edit, onboarding provider step, audit wording, and neutrality guard coverage; `Heavy-Governance`: none; `Browser`: none
- **Affected validation lanes**: `fast-feedback`, `confidence`
- **Why this lane mix is the narrowest sufficient proof**: The risk is shared semantic drift and operator-facing meaning on existing admin surfaces, not browser interaction or long-running runtime behavior. Unit tests prove the neutral contract, while feature tests prove the same meaning across the real surfaces and authorization contexts.
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- **Fixture / helper / factory / seed / context cost risks**: low to moderate; reuse existing `ProviderConnectionFactory`, workspace and tenant membership fixtures, and current provider connection audit helpers. Do not add a new default provider-world helper.
- **Expensive defaults or shared helper growth introduced?**: no
- **Heavy-family additions, promotions, or visibility changes**: none
- **Surface-class relief / special coverage rule**: standard native Filament relief for the provider connection resource plus one onboarding wizard-step extension
- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test commands above. Reviewers should verify that shared labels and required fields are neutral by default, Microsoft contextual details still appear where genuinely needed, audit prose stays aligned, and no generic provider surface regained `Entra tenant ID` as default shared truth.
- **Budget / baseline / trend follow-up**: none expected
- **Review-stop questions**: Did the slice drift into broad identity migration or credential-model redesign? Did any test or helper make Microsoft context implicit by default? Did any shared surface keep Microsoft-shaped default labels or required fields? Did audit or validation copy remain provider-shaped on shared paths?
- **Escalation path**: `document-in-feature`
- **Active feature PR close-out entry**: `Guardrail`
- **Why no dedicated follow-up spec is needed**: This feature resolves the concrete provider-connection target-scope hotspot directly. Later governed-subject and compare-boundary work is already explicitly tracked separately.
## Project Structure
### Documentation (this feature)
```text
specs/238-provider-identity-target-scope/
├── spec.md
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── provider-identity-target-scope.logical.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php
│ │ └── Resources/ProviderConnectionResource.php
│ ├── Models/
│ │ └── ProviderConnection.php
│ ├── Services/
│ │ └── Providers/
│ │ ├── PlatformProviderIdentityResolver.php
│ │ ├── ProviderConnectionMutationService.php
│ │ ├── ProviderConnectionResolution.php
│ │ ├── ProviderConnectionResolver.php
│ │ ├── ProviderConnectionStateProjector.php
│ │ ├── ProviderIdentityResolution.php
│ │ └── ProviderIdentityResolver.php
│ └── Support/
│ └── Providers/
│ └── TargetScope/
└── tests/
├── Feature/
│ ├── Audit/
│ ├── Filament/
│ ├── Guards/
│ ├── ManagedTenantOnboardingWizardTest.php
│ └── ProviderConnections/
└── Unit/
└── Providers/
```
**Structure Decision**: Keep the entire slice inside the existing Laravel runtime in `apps/platform`. The only new code shape planned is a small `Support/Providers/TargetScope` helper namespace or equivalent small support layer. Runtime changes stay inside existing provider connection resources and provider identity or connection services.
## Complexity Tracking
No constitutional violation is planned. One bounded complexity addition is tracked because the feature introduces a new shared descriptor layer.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| BLOAT-001 bounded target-scope descriptor | Multiple real surfaces and shared audit copy already need the same neutral provider-connection truth, and the current shared path still exposes Microsoft-shaped default semantics | Page-local label rewrites would not fix the shared contract or stop future regressions on the next surface |
## Proportionality Review
- **Current operator problem**: Shared provider connection setup and inspection still teach Microsoft-specific identity as if it were the platform's default connection truth, which causes wrong operator assumptions and future provider-boundary drift.
- **Existing structure is insufficient because**: current resolution objects and resource schemas mix neutral provider-connection meaning with Microsoft-only fields such as `entra_tenant_id`, `tenantContext`, and `authorityTenant`.
- **Narrowest correct implementation**: add one shared target-scope descriptor and summary mapping layer, route shared labels and validation through it, and keep Microsoft-specific detail contextual instead of rewriting the full provider architecture.
- **Ownership cost created**: one small support namespace, one focused unit seam, a handful of extended feature and audit tests, and stricter review expectations on shared provider connection surfaces.
- **Alternative intentionally rejected**: reusing the broader migration and credential redesign scope from Spec 137. It would widen the slice beyond the current hotspot and obscure the real shared-contract goal.
- **Release truth**: current-release truth with bounded anti-drift hardening
## Phase 0 Research Summary
- The slice should reuse existing provider connection surfaces and services, not create a new provider-management framework.
- `ProviderConnectionResource` is a concrete hotspot because `entra_tenant_id` is still the required shared form field and a default list or detail summary.
- `ProviderIdentityResolution` is a second hotspot because shared identity output still uses Microsoft-shaped terms such as `tenantContext` and `authorityTenant` as if they were default shared semantics.
- Neutral target-scope truth should live in a small shared descriptor or summary layer used by forms, infolists, tables, onboarding, validation, and audit wording.
- Microsoft tenant, directory, consent, and authority details should remain contextual provider-owned metadata instead of disappearing or becoming the shared default contract.
- Focused unit and feature tests are sufficient; browser or heavy-governance coverage would add cost without proving unique behavior.
## Phase 1 Design Summary
- `research.md` captures the bounded contract and vocabulary decisions that keep the slice narrow.
- `data-model.md` defines the target-scope descriptor, provider-owned identity metadata, operator-facing surface summary, and guard result shape.
- `contracts/provider-identity-target-scope.logical.openapi.yaml` defines the internal logical contract for reading normalized target-scope summaries and evaluating neutrality drift.
- `quickstart.md` records the narrow implementation order and the validation sequence.
- `tasks.md` should sequence the work from target-scope descriptor foundation through shared surface adoption, audit wording alignment, and guard coverage.
## Implementation Close-Out Notes
- The implemented slice adds `App\Support\Providers\TargetScope` as the bounded target-scope support layer for descriptors, normalization, provider-owned contextual identity metadata, and surface summaries.
- Provider connection create, edit, list, detail, onboarding, identity-resolution, verification, health-check, and shared audit paths now carry neutral `target_scope` truth beside `provider_identity_context` metadata.
- Existing Microsoft runtime truth remains intentionally bounded: `entra_tenant_id` is still the persisted/runtime provider column, while shared operator surfaces use `Target scope` or `Target scope ID` by default and show `Microsoft tenant ID` only as contextual provider-owned detail.
- Unsupported provider and target-scope combinations now fail explicitly through the normalizer and provider connection resolver instead of falling through to Microsoft defaults.
- Existing security-sensitive provider connection actions remain confirmation-gated and capability-gated; this slice adds guard coverage rather than changing the action model.
- Close-out disposition remains `document-in-feature`. Broader governed-subject, compare-boundary, second-provider runtime, and credential-model redesign work stays deferred to follow-up specs.
## Phase 1 — Agent Context Update
Run after artifact generation:
- `.specify/scripts/bash/update-agent-context.sh copilot`
## Implementation Strategy
### Phase A — Add the shared target-scope descriptor
**Goal**: Introduce one neutral shared description of what a provider connection points to.
| Step | File | Change |
|------|------|--------|
| A.1 | `apps/platform/app/Support/Providers/TargetScope/*` | Add a small target-scope descriptor and summary helper layer that can express `provider`, `scope_kind`, `scope_identifier`, and `scope_display_name` as neutral truth while carrying provider-owned contextual details only in companion metadata or summary shapes. |
| A.2 | `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `ProviderIdentityResolution.php` | Stop treating Microsoft-shaped identity output as the default shared semantic contract and expose neutral target-scope data for shared surfaces. |
| A.3 | `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php` | Prove the descriptor stays neutral by default and still allows bounded Microsoft contextual metadata. |
### Phase B — Adopt the descriptor on shared provider connection surfaces
**Goal**: Replace Microsoft-shaped default labels and summaries on the existing shared resource surfaces.
| Step | File | Change |
|------|------|--------|
| B.1 | `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` | Replace `Entra tenant ID` as the default shared field or summary label with neutral target-scope wording on form, infolist, and table surfaces while keeping Microsoft-specific detail contextual. |
| B.2 | `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` or adjacent summary helpers | Align default-visible connection summaries with the same neutral descriptor used by the resource. |
| B.3 | `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php` and existing Filament UI enforcement coverage | Prove list, detail, create, and edit surfaces show neutral target-scope truth first and preserve Microsoft-specific detail only contextually. |
### Phase C — Extend onboarding and mutation or audit wording
**Goal**: Keep setup, validation, and audit semantics aligned with the same shared contract.
| Step | File | Change |
|------|------|--------|
| C.1 | `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Reuse the same target-scope descriptor in the onboarding provider setup step so the operator sees the same neutral meaning before continuing. |
| C.2 | `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` and related audit writers | Align create or update validation messages and audit wording with neutral provider and target-scope vocabulary while preserving provider context. |
| C.3 | `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` | Prove audit entries and onboarding copy use the same neutral target-scope contract. |
### Phase D — Add guardrails and finish bounded drift protection
**Goal**: Keep the hotspot closed without widening into a broader provider rewrite.
| Step | File | Change |
|------|------|--------|
| D.1 | `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php` | Prove shared identity-resolution output no longer requires Microsoft-shaped default terms. |
| D.2 | `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php` | Block new Microsoft-specific default labels, filters, validation messages, helper copy, or audit prose on shared provider connection surfaces unless the path is explicitly provider-owned. |
| D.3 | `specs/238-provider-identity-target-scope/quickstart.md` and later `tasks.md` | Keep the neutral-target-scope contract, bounded Microsoft contextual metadata, and no-marketplace/no-second-provider guardrail explicit. |
## Risks and Mitigations
- **Scope creep into Spec 137**: Connection-type and credential redesign could get pulled back into this slice. Mitigation: keep the implementation centered on shared target-scope semantics only and leave connection-type migration out of scope.
- **Over-abstracting the descriptor**: A broad multi-provider descriptor framework could appear. Mitigation: keep the new support layer small and only as rich as the currently touched provider connection surfaces require.
- **Audit or validation drift**: Shared UI labels may become neutral while audit prose or validation messages stay Microsoft-shaped. Mitigation: extend existing audit and mutation coverage in the same slice.
- **Hidden Microsoft default leakage**: A later surface might reintroduce `Entra tenant ID` as the default shared label. Mitigation: add one focused guard test for shared provider connection surfaces.
- **Onboarding mismatch**: Provider connection surfaces may become neutral while onboarding still shows Microsoft-first setup meaning. Mitigation: include the onboarding provider step in the first implementation slice and in validation coverage.
## Post-Design Re-check
Phase 0 and Phase 1 outputs keep the feature constitution-compliant, Filament v5 and Livewire v4 compliant, and intentionally narrow. The plan introduces no new persistence, no second provider runtime, no provider marketplace workflow, and no broad identity-migration framework. It is ready for `/speckit.tasks`.

View File

@ -0,0 +1,82 @@
# Quickstart: Provider Identity & Target Scope Neutrality
## Goal
Implement the shared provider connection target-scope contract so generic provider surfaces stop treating Microsoft identity as the default meaning of a connection.
## Implementation Sequence
1. Add the small shared target-scope descriptor and summary helper layer.
2. Refactor shared provider connection and identity-resolution outputs so neutral target-scope truth is available without Microsoft-shaped default labels.
3. Update provider connection list, detail, create, and edit surfaces to use neutral target-scope language by default.
4. Update the onboarding provider setup step and shared audit and validation wording to reuse the same neutral contract.
5. Add focused guardrails that block Microsoft-specific default labels, filters, required fields, validation messages, helper copy, and audit prose from reappearing on shared provider connection surfaces.
## Suggested Code Areas
```text
apps/platform/app/Filament/Resources/ProviderConnectionResource.php
apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
apps/platform/app/Services/Providers/
apps/platform/app/Support/Providers/TargetScope/
apps/platform/tests/Feature/Audit/
apps/platform/tests/Feature/Filament/
apps/platform/tests/Feature/ProviderConnections/
apps/platform/tests/Feature/Guards/
apps/platform/tests/Unit/Providers/
```
## Verification Commands
Run the narrowest shared-contract proof first:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php
```
Then run the shared-surface and onboarding proof:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php
```
Then run the audit and guardrail proof:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php
```
If PHP files changed, finish with formatting:
```bash
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
```
## Review Focus
- Confirm shared provider connection forms, tables, and infolists no longer use `Entra tenant ID` as the default shared label or required field.
- Confirm the shared target-scope descriptor remains understandable without provider-specific vocabulary.
- Confirm unsupported provider or target-scope combinations and missing-context paths fail explicitly instead of inheriting Microsoft defaults.
- Confirm Microsoft tenant, directory, and consent details remain available only as contextual provider-owned metadata.
- Confirm unchanged `404` versus `403` behavior and confirmation-gated sensitive actions are preserved on the touched shared surfaces.
- Confirm onboarding uses the same target-scope meaning as the provider connection resource.
- Confirm audit and validation wording follow the same provider and target-scope vocabulary.
- Confirm no broader credential-model, second-provider, or marketplace scope slipped into the slice.
## Guardrail Close-Out
- Validation to complete before final handoff:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- Guardrails checked:
- No new provider runtime or provider marketplace abstraction.
- No new persistence or schema rewrite.
- No Microsoft-specific default labels, filters, required fields, validation messages, helper copy, or audit prose on shared provider connection surfaces.
- Unchanged `404` versus `403` behavior and confirmation-gated sensitive actions remain intact on the touched shared surfaces.
- Microsoft contextual identity remains available where current-release workflows genuinely need it.
- Implemented close-out:
- Shared provider connection surfaces now use `Target scope` vocabulary by default.
- Provider-owned Microsoft details are carried in `provider_identity_context` and diagnostic labels such as `Microsoft tenant ID`.
- Create, update, verification, health-check, and onboarding audit metadata carries `target_scope` plus provider context instead of promoting a raw Microsoft tenant field as shared truth.
- Existing Filament table contracts for provider connections were updated to reflect provider and target scope as default-visible summary columns.
- Close-out decision: `document-in-feature`. The shared provider connection target-scope hotspot is closed here; broader cross-domain provider-boundary work remains separately tracked.

View File

@ -0,0 +1,41 @@
# Research: Provider Identity & Target Scope Neutrality
## Decision 1: Use one small shared target-scope descriptor instead of a broad provider identity framework
- **Decision**: Introduce one small shared descriptor for provider connection target scope and reuse it across the provider connection resource, onboarding, validation, and audit wording.
- **Rationale**: The current release needs one neutral shared truth for multiple real surfaces, not a generalized provider marketplace or identity framework. A small descriptor layer is enough to keep shared language neutral while still letting Microsoft-specific detail remain contextual.
- **Alternatives considered**:
- Page-local label cleanup only: rejected because it would leave the shared contract Microsoft-shaped underneath.
- Broad provider identity abstraction: rejected because there is still only one shipped provider runtime and the current hotspot is narrower than that.
## Decision 2: Keep Microsoft tenant and directory details as provider-owned contextual metadata
- **Decision**: Retain `entra_tenant_id`, authority-tenant details, consent wording, and Microsoft verification details only as contextual provider-owned metadata on Microsoft paths.
- **Rationale**: Operators still need Microsoft-specific identifiers for consent and troubleshooting, but those identifiers should not define the default meaning of a provider connection on generic shared surfaces.
- **Alternatives considered**:
- Remove Microsoft-specific details from the UI entirely: rejected because the current product still needs them on Microsoft-only workflows.
- Keep them as the default connection summary: rejected because that preserves the current provider-boundary drift.
## Decision 3: Neutralize shared Filament surfaces first, not every provider term in the repo
- **Decision**: Limit the first slice to provider connection list, detail, create, edit, onboarding provider setup, and shared audit or validation wording directly tied to those surfaces.
- **Rationale**: These are the concrete operator-facing hotspots already named in the spec. A repo-wide terminology sweep would widen scope without improving the core shared contract any faster.
- **Alternatives considered**:
- Rename every provider-related term immediately: rejected because it would turn one bounded hotspot into a broad copy and architecture sweep.
- Leave onboarding for later: rejected because it would preserve two competing interpretations of the same connection truth.
## Decision 4: Anchor neutrality in shared resolution and mutation paths, not only in UI labels
- **Decision**: Update the existing provider connection and identity-resolution outputs plus mutation and audit wording so shared surfaces all consume the same neutral target-scope semantics.
- **Rationale**: UI-only changes would be fragile because validation, audit prose, and future surfaces would still source their meaning from Microsoft-shaped service outputs.
- **Alternatives considered**:
- Keep service outputs unchanged and translate everything in Filament only: rejected because future surfaces would likely repeat the same drift.
- Replace the entire provider identity stack: rejected because the current hotspot is limited to shared target-scope meaning.
## Decision 5: Enforce the contract with focused guardrails, not browser coverage
- **Decision**: Add focused unit and feature guard coverage for neutral target-scope descriptors, shared surface labels, onboarding reuse, and audit wording.
- **Rationale**: The risk is semantic drift in shared provider connection truth, not browser-only interaction. Narrow unit and feature coverage are the cheapest proof.
- **Alternatives considered**:
- Browser tests: rejected because they add cost without proving unique behavior for this slice.
- Manual review only: rejected because the feature exists to stop the same hotspot from reopening quietly.

View File

@ -0,0 +1,310 @@
# Feature Specification: Provider Identity & Target Scope Neutrality
**Feature Branch**: `238-provider-identity-target-scope`
**Created**: 2026-04-24
**Status**: Draft
**Input**: User description: "Promote the next roadmap-fit spec candidate: Provider Identity & Target Scope Neutrality"
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
- **Problem**: Shared provider connection semantics still mix neutral platform language with Microsoft-specific tenant identity assumptions, so operators and maintainers cannot tell where platform truth ends and provider detail begins.
- **Today's failure**: Generic-looking provider surfaces and validation paths still imply that a provider connection is fundamentally an Entra tenant binding, which teaches the wrong mental model to operators and quietly deepens provider coupling in shared code.
- **User-visible improvement**: Provider connection create, edit, list, detail, and onboarding-adjacent views describe the connection and its target scope consistently, while Microsoft-specific identity details remain available only when that context is actually needed.
- **Smallest enterprise-capable version**: Neutralize the in-scope provider connection and target-scope contract plus its default UI vocabulary, while preserving current Microsoft onboarding, consent, and verification behavior as bounded provider-specific detail.
- **Architecture center**: The primary deliverable is the shared provider connection target-scope contract. UI vocabulary changes are acceptance evidence for that contract, not the architectural center of the feature.
- **Explicit non-goals**: No second-provider runtime, no provider marketplace, no broad credential model redesign, no replay of the much wider Spec 137 migration scope, no compare-engine changes, and no generic multi-cloud onboarding framework.
- **Permanent complexity imported**: One narrower neutral target-scope contract for shared provider connection truth, one explicit distinction between shared target-scope semantics and provider-owned identity metadata, and focused regression coverage for shared labels, validation, and unsupported-path behavior.
- **Why now**: The roadmap and spec-candidates sequence place this as the next anti-drift hotspot immediately after Spec 237, because leaving provider identity and target scope Microsoft-shaped would undermine the newly hardened boundary before more provider-backed work lands.
- **Why not local**: The problem spans shared persistence semantics, list and detail vocabulary, create and edit flows, onboarding-adjacent copy, and validation behavior. A one-surface copy cleanup would leave the underlying shared contract and other surfaces free to drift again.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Foundation-sounding scope and new boundary vocabulary. Defense: the slice stays bounded to provider connection identity and target-scope semantics, preserves Microsoft-first current product truth, and avoids speculative runtime or marketplace expansion.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitat: 1 | Produktnahe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace, tenant
- **Primary Routes**:
- Existing provider connection collection, create, edit, and detail surfaces under `/admin/provider-connections`
- Existing tenant-scoped onboarding or connection-setup flows under `/admin/t/{tenant}/...` where provider identity and target scope are shown or chosen
- Existing consent, verification, and provider-readiness surfaces that display shared connection identity or target-scope summaries
- **Data Ownership**:
- `provider_connections` remain tenant-owned operational records
- No new tenant-owned or workspace-owned business entity is introduced
- Provider-specific identity metadata remains provider-owned detail attached to existing connection truth rather than a new shared platform object
- **RBAC**:
- Existing workspace membership remains required for all provider connection surfaces
- Existing tenant entitlement remains required on tenant-context surfaces
- Existing provider-connection management capabilities continue to authorize create, edit, verification, and consent-adjacent mutations
- This spec does not introduce a new top-level capability or relax current 404 versus 403 semantics
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: N/A - this slice does not add a new canonical-view surface
- **Explicit entitlement checks preventing cross-tenant leakage**: N/A - in-scope work stays on existing workspace-admin provider-connection surfaces plus existing tenant-scoped onboarding and provider-setup surfaces
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: shared provider connection vocabulary, detail summaries, form labels, status messaging, and audit prose
- **Systems touched**: provider connection list, detail, create, edit, onboarding-adjacent setup steps, consent and verification summaries, and any shared filters or helper copy that describe connection target scope
- **Existing pattern(s) to extend**: Spec 237 boundary ownership classification, existing provider connection resource and detail surfaces, and the current shared provider connection resolution path
- **Shared contract / presenter / builder / renderer to reuse**: the existing provider connection management surfaces and the current shared provider connection resolution and summary path remain the single shared path; this spec narrows their vocabulary and target-scope semantics rather than introducing a parallel presenter stack
- **Why the existing shared path is sufficient or insufficient**: the current shared path is sufficient for routing, authorization, and current provider-backed behavior, but it is insufficient because it still treats Microsoft-shaped identity and target-scope semantics as the implied default on generic provider surfaces
- **Allowed deviation and why**: bounded provider-specific descriptors are allowed when the selected provider is Microsoft and the operator genuinely needs tenant- or directory-specific context for consent, troubleshooting, or verification
- **Consistency impact**: provider, provider connection, target scope, consent state, and verification state must use one shared vocabulary across form labels, table columns, detail summaries, validation feedback, and audit prose
- **Review focus**: reviewers must block any generic provider connection surface or shared validation path that reintroduces Microsoft-specific field names, required labels, or defaults without explicit provider-owned justification
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: N/A
- **Delegated start/completion UX behaviors**: N/A
- **Local surface-owned behavior that remains**: N/A
- **Queued DB-notification policy**: N/A
- **Terminal notification path**: N/A
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
- **Shared provider/platform boundary touched?**: yes
- **Boundary classification**: mixed
- **Seams affected**: shared provider connection semantics, target-scope descriptors, create and edit field vocabulary, list and detail summaries, validation rules, filter labels, consent and verification summaries, and audit wording
- **Neutral platform terms preserved or introduced**: provider, provider connection, target scope, scope identifier, scope display name, consent state, verification state, readiness summary
- **Provider-specific semantics retained and why**: Microsoft tenant ID, directory ID, admin-consent wording, and Microsoft-specific verification details remain contextual provider-owned detail because current-release truth is still Microsoft-first and operators still need those descriptors on Microsoft paths
- **Why this does not deepen provider coupling accidentally**: the shared platform contract is reduced to neutral connection and target-scope truth, while Microsoft-specific identifiers remain contextual metadata instead of required shared platform fields or default operator vocabulary
- **Follow-up path**: follow-up-spec for broader governed-subject and compare-boundary work; the provider connection identity and target-scope hotspot is resolved in-feature
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Provider connection list and detail | yes | Native Filament resource and infolist primitives | shared provider connection family | table, detail | no | Default language becomes target-scope neutral; Microsoft descriptors stay contextual |
| Provider connection create and edit | yes | Native Filament forms and sections | shared provider connection family | form, section | no | Existing create and edit flow remains; only shared contract and vocabulary are narrowed |
| Tenant onboarding provider step | yes | Native wizard and action surfaces | shared onboarding and provider family | wizard step | no | Existing onboarding path remains; scope and identity language become neutral by default |
## 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 |
|---|---|---|---|---|---|---|---|
| Provider connection list and detail | Primary Decision Surface | Decide whether the connection points at the right target scope and what follow-up action is needed | Provider, target scope, consent state, verification state, and readiness summary | Raw provider-specific identifiers, consent diagnostics, low-level verification detail | Primary because this is the operational management surface where operators decide whether the connection is correctly scoped and usable | Follows provider setup and troubleshooting workflow rather than storage internals | Removes the need to infer meaning from raw tenant identifiers or mixed status labels |
| Provider connection create and edit | Primary Decision Surface | Choose the connection target and save it with the correct scope semantics | Provider choice, target-scope summary, required neutral fields, and provider-specific fields only when relevant | Secondary provider-specific guidance and troubleshooting detail | Primary because the operator is defining connection truth here, not just inspecting it later | Follows setup and correction workflow directly | Prevents incorrect scope choices caused by Microsoft-first default wording |
| Tenant onboarding provider step | Primary Decision Surface | Confirm that onboarding is connecting the intended target scope before continuing | Provider, target-scope summary, and the next action needed to continue onboarding | Provider-specific context such as Microsoft tenant identity when selected | Primary because onboarding must answer what is being connected before the operator commits to the next step | Keeps onboarding focused on the current tenant workflow | Reduces ambiguity between platform-neutral setup and Microsoft-specific details |
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Provider connection list and detail | List / Table / Bulk | CRUD / List-first Resource | Inspect or edit the connection target scope | Full-row click to detail | allowed | One inline safe shortcut plus More | More or detail header only | /admin/provider-connections | existing provider connection detail route | Workspace, provider, target scope | Provider connections / Provider connection | What scope the connection represents and whether it is consented and verified | none |
| Provider connection create and edit | Record / Detail / Edit | Create/Edit Form | Save the connection with the correct target scope | Explicit create or edit page only | forbidden | Secondary guidance lives in section help text, not competing actions | Existing dangerous lifecycle actions remain off the create page and grouped on detail or edit where already supported | /admin/provider-connections | existing provider connection detail route after save | Provider and target scope | Provider connection | Which target scope will be connected and which fields are provider-neutral versus provider-specific | none |
| Tenant onboarding provider step | Workflow / Wizard / Launch | Wizard / Step-driven Flow | Continue onboarding with the confirmed provider target scope | Existing onboarding step | forbidden | Secondary navigation remains contextual only | Destructive actions are out of scope | existing tenant onboarding route | existing onboarding step route | Workspace, tenant, provider, target scope | Onboarding / Provider setup step | Which scope is being connected before the operator continues | 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Provider connection list and detail | Workspace owner, tenant manager, support operator | Decide whether a provider connection is correctly scoped and ready for use | List and detail | What does this connection point to, and is it ready? | Provider, target scope, consent state, verification state, readiness summary | Raw tenant or directory identifiers, provider-specific verification detail | consent, verification, readiness | TenantPilot only for inspection; Microsoft tenant only when operator triggers existing consent or verification actions | View connection, Edit connection, Retry consent, Run verification | Existing destructive lifecycle actions only where already supported |
| Provider connection create and edit | Workspace owner or tenant manager | Create or correct a provider connection without encoding the wrong target-scope meaning | Create/Edit form | Am I connecting the correct scope, and are these the right fields for this provider? | Provider selection, target-scope summary, neutral required fields, contextual provider-specific fields | Provider-specific troubleshooting guidance | readiness prerequisites only | TenantPilot only until existing provider-facing consent or verification steps are invoked | Save connection, Cancel | none added |
| Tenant onboarding provider step | Tenant manager or onboarding operator | Continue onboarding with the intended provider target scope | Wizard step | What target scope am I connecting right now? | Provider, target-scope summary, current onboarding next step | Provider-specific detail needed for consent or troubleshooting | onboarding progress, consent readiness, verification readiness | TenantPilot onboarding flow and existing provider-facing consent actions | Continue onboarding, Open connection setup where already present | none added |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: yes
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Operators and maintainers must currently infer shared provider connection meaning from Microsoft-specific labels and identifiers, which creates wrong setup decisions and teaches the wrong platform truth.
- **Existing structure is insufficient because**: Spec 237 classifies seam ownership but does not yet narrow the concrete connection and target-scope contract where Microsoft-shaped semantics are still visible by default.
- **Narrowest correct implementation**: Reuse the existing provider connection surfaces and resolution path, replace only the shared identity and target-scope descriptor/contract semantics, and keep provider-specific detail contextual instead of building a new provider framework.
- **Ownership cost**: The codebase must keep one neutral vocabulary for shared provider connection truth, maintain focused tests that guard default labels and validation behavior, and review provider-specific additions more carefully on shared surfaces.
- **Alternative intentionally rejected**: Reusing the broader old Spec 137 scope was rejected because it mixes identity migration, dedicated credential design, and larger onboarding changes. Pure copy cleanup was rejected because it would leave the shared contract Microsoft-shaped underneath.
- **Release truth**: current-release truth with bounded anti-drift hardening
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Unit, Feature
- **Validation lane(s)**: fast-feedback, confidence
- **Why this classification and these lanes are sufficient**: the change is proven by shared contract behavior plus operator-visible list, detail, create, edit, and onboarding semantics. Narrow unit coverage proves neutral target-scope mapping and validation, while focused feature coverage proves the operator-visible surfaces and authorization behavior.
- **New or expanded test families**: focused provider connection neutrality guard tests only
- **Fixture / helper cost impact**: low to moderate; tests need existing workspace, tenant, membership, provider connection, and provider-specific metadata fixtures, but no new heavy provider harness or browser stack
- **Heavy-family visibility / justification**: none
- **Special surface test profile**: standard-native-filament
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient; no browser or heavy-governance lane is justified for this slice
- **Reviewer handoff**: reviewers must confirm that tests prove default neutral target-scope semantics, contextual Microsoft detail retention, unchanged 404 versus 403 behavior, and explicit unsupported-path handling rather than only asserting copy fragments
- **Budget / baseline / trend impact**: none expected beyond a small increase in provider connection feature assertions
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail
- **Planned validation commands**:
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php tests/Unit/Badges/ProviderConnectionBadgesTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php tests/Feature/ManagedTenantOnboardingWizardTest.php`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Choose the Right Target Scope (Priority: P1)
As a workspace or tenant admin, I want the provider connection setup flow to describe the target scope in neutral platform language so I can tell what I am connecting before I save it.
**Why this priority**: This is the main operator-facing trust problem. If setup still implies that every connection is fundamentally an Entra tenant binding, the shared platform contract remains misleading.
**Independent Test**: Can be fully tested by opening the create or edit flow, choosing an in-scope provider, and confirming that shared fields and summaries use target-scope language while provider-specific fields appear only when the selected provider needs them.
**Acceptance Scenarios**:
1. **Given** an operator opens the provider connection create flow, **When** they review the default shared fields, **Then** the form describes provider and target scope in neutral platform language rather than Microsoft-specific defaults.
2. **Given** the selected provider is Microsoft, **When** the operator reaches provider-specific consent or verification context, **Then** the UI may show tenant or directory-specific detail without redefining the shared target-scope meaning.
---
### User Story 2 - Inspect Connection Meaning Without Guesswork (Priority: P1)
As an operator inspecting existing provider connections, I want list and detail surfaces to show provider, target scope, consent state, and verification state separately so I can understand what the connection represents and what follow-up is needed.
**Why this priority**: The day-to-day management surface must become trustworthy. If operators still need to infer meaning from raw IDs or mixed status fields, the spec has not delivered its main value.
**Independent Test**: Can be fully tested by loading the list and detail surfaces for representative provider connections and confirming that the shared summary stays neutral while provider-specific identifiers remain secondary detail.
**Acceptance Scenarios**:
1. **Given** a provider connection is listed on the shared resource page, **When** the operator scans the row, **Then** they can see the provider, target scope, consent state, and verification state without reading raw provider-specific IDs.
2. **Given** the operator opens connection detail, **When** provider-specific Microsoft information exists, **Then** it appears as contextual detail rather than the primary shared identity of the connection.
---
### User Story 3 - Extend Shared Provider Surfaces Safely (Priority: P2)
As a maintainer or reviewer, I want shared provider connection semantics to stay neutral by default so future provider-related changes do not silently reintroduce Microsoft-first platform truth.
**Why this priority**: The slice is justified not only by current UI clarity but by preventing the same hotspot from reopening as more provider-backed work lands.
**Independent Test**: Can be fully tested by exercising the focused regression coverage and confirming that a generic provider surface cannot require Microsoft-specific shared fields or default labels without explicit provider-owned justification.
**Acceptance Scenarios**:
1. **Given** a generic provider connection surface or shared validation path is extended, **When** Microsoft-specific identity labels are introduced as the default shared contract, **Then** focused regression coverage fails visibly.
2. **Given** a Microsoft-specific surface still needs tenant or directory detail, **When** the same coverage runs, **Then** the contextual provider-owned detail remains allowed without forcing those labels into the generic contract.
### Edge Cases
- A provider connection may already have valid target-scope summary data but be missing provider-specific Microsoft identity detail, and the shared surface must stay interpretable without pretending the connection is fully ready.
- A Microsoft connection may legitimately need tenant or directory identifiers for consent or troubleshooting, but those identifiers must not become the default label set on generic provider surfaces.
- Existing rows may still contain legacy Microsoft-shaped fields; in-scope surfaces must present neutral target-scope truth without silently promoting legacy fields back into shared platform defaults.
- Unauthorized users must not learn target-scope or provider identity details through list filters, detail summaries, or onboarding-adjacent helper copy.
- Changing the selected provider in a create or edit flow must update required fields and helper copy without leaving stale Microsoft-specific wording behind.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph endpoints, new long-running operations, or new persisted business entities. It narrows the shared contract and UI semantics for existing provider connection create, edit, view, consent, and verification flows. Any in-scope create or update mutation remains server-authorized and auditable.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds one narrower shared abstraction for target-scope semantics because the existing shared connection contract still encodes provider-specific truth by default. It does not add new persistence, new state families, or new cross-domain UI taxonomy.
**Constitution alignment (XCUT-001):** This feature is cross-cutting across provider connection forms, lists, details, onboarding-adjacent setup, validation feedback, and audit prose. It extends the existing shared provider connection path rather than introducing a second presenter or page-local vocabulary layer.
**Constitution alignment (PROV-001):** Shared provider connection and target-scope truth remain platform-core. Provider-specific identity descriptors, consent wording, and Microsoft verification detail remain provider-owned. The spec keeps provider-specific semantics out of the default shared contract and records broader governed-subject and compare semantics as later follow-up work.
**Constitution alignment (TEST-GOV-001):** Proof stays in narrow unit and feature lanes. No browser or heavy-governance family is added. Any helper introduced for provider connection neutrality coverage must keep workspace, tenant, membership, and provider context explicit rather than default.
**Constitution alignment (OPS-UX):** N/A - this slice does not create, start, or redesign an `OperationRun`.
**Constitution alignment (OPS-UX-START-001):** N/A - no `OperationRun` start or link semantics are changed.
**Constitution alignment (RBAC-UX):** In-scope surfaces remain on the tenant and workspace admin plane. Non-members or actors lacking tenant entitlement remain 404. Members lacking the relevant capability remain 403. Authorization stays server-side for create, edit, consent-adjacent, and verification mutations. Existing security-sensitive actions remain confirmation-gated and audited.
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
**Constitution alignment (BADGE-001):** Consent and verification labels remain centralized; this spec does not introduce a new badge family or a second mapping layer.
**Constitution alignment (UI-FIL-001):** UI-FIL-001 is satisfied. In-scope operator-facing changes stay on native Filament forms, tables, infolists, sections, and existing shared UI primitives. No local replacement markup or page-local status language is introduced.
**Constitution alignment (UI-NAMING-001):** The target object is the provider connection and its target scope. Operator verbs remain existing verbs such as connect, edit, retry consent, and run verification. The same provider and target-scope vocabulary must be preserved across buttons, headings, helper copy, validation feedback, and audit prose.
**Constitution alignment (DECIDE-001):** The affected provider connection and onboarding surfaces are primary decision surfaces because they answer what is being connected and whether the connection is correctly scoped. Provider-specific diagnostics remain secondary detail.
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The chosen action-surface classes, inspect models, grouped action placement, scope signals, canonical nouns, and default-visible truth are captured in the surface tables above. No exception is required.
**Constitution alignment (ACTSURF-001 - action hierarchy):** This spec does not add new action families. Existing navigation and mutation placement remain unchanged, and no mixed catch-all action structure is introduced.
**Constitution alignment (OPSURF-001):** The default-visible content stays operator-first by surfacing provider, target scope, consent state, and verification state before raw provider-specific identifiers. Diagnostics stay secondary and contextual.
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature introduces only the minimum shared semantic tightening required to keep provider-specific identity detail from masquerading as platform-core truth. Tests focus on operator-visible meaning and boundary consequences rather than thin presentation indirection alone.
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied. Each affected surface keeps one primary inspect or open model, redundant View actions remain absent, empty action groups remain absent, and destructive actions stay in their existing safe placements. The UI Action Matrix below records the in-scope surfaces.
**Constitution alignment (UX-001 — Layout & Information Architecture):** In-scope create and edit forms continue to use the existing sectioned Filament layout, view pages continue to use the existing detail or infolist presentation, empty states keep one primary CTA, and tables continue to expose core searchable and filterable dimensions such as provider and target scope. No UX-001 exemption is required.
### Functional Requirements
- **FR-238-001**: The shared provider connection contract MUST distinguish neutral provider-connection truth from provider-specific identity metadata.
- **FR-238-002**: In-scope shared create, edit, list, and detail surfaces MUST use neutral default vocabulary for provider connection and target scope.
- **FR-238-003**: The shared provider connection target-scope descriptor/contract MUST remain understandable through neutral fields such as scope kind, scope identifier, and scope display name without assuming Microsoft directory semantics as the default meaning.
- **FR-238-004**: Microsoft-specific tenant, directory, consent, and verification descriptors MAY appear only as contextual provider-owned detail when the selected provider is Microsoft.
- **FR-238-005**: Shared validation and persistence paths MUST NOT require Microsoft-specific fields unless the selected provider explicitly needs them through provider-owned rules.
- **FR-238-006**: Provider connection list and detail surfaces MUST show provider, target scope, consent state, and verification state as separate default-visible dimensions.
- **FR-238-007**: Shared filters, columns, section headings, and detail summaries for provider connections MUST NOT use Microsoft-specific nouns as the default labels.
- **FR-238-008**: Existing Microsoft onboarding, consent, and verification flows MUST preserve the provider-specific detail operators need without redefining the shared target-scope contract.
- **FR-238-009**: Unsupported provider or target-scope combinations encountered on shared paths MUST fail explicitly rather than inheriting Microsoft defaults.
- **FR-238-010**: Existing authorization behavior for provider connection surfaces MUST remain unchanged, including 404 versus 403 semantics.
- **FR-238-011**: Existing security-sensitive provider connection mutations MUST remain confirmation-gated and auditable.
- **FR-238-012**: The first implementation slice MUST cover provider connection list, detail, create, edit, and tenant onboarding-adjacent setup surfaces.
- **FR-238-013**: The feature MUST NOT introduce a second-provider runtime, provider marketplace, or broad credential model redesign.
- **FR-238-014**: Audit events for in-scope provider connection create or update flows MUST describe target scope in neutral vocabulary while still recording provider context.
- **FR-238-015**: Regression coverage MUST fail if a generic provider connection surface reintroduces Microsoft-specific default labels or required shared fields without explicit provider-owned justification.
- **FR-238-016**: Shared provider/platform seams MUST NOT introduce new Microsoft-specific default labels, filter labels, required fields, validation messages, audit prose, or helper copy unless the path is explicitly provider-owned and scoped to the Microsoft provider.
- **FR-238-017**: Any new target-scope helper, descriptor, or normalization path MUST preserve the distinction between neutral platform scope truth and provider-owned identity metadata.
### Non-Goals
- Reopening the broader connection-type and credential migration work from Spec 137
- Adding a second provider or a provider marketplace workflow
- Redesigning provider verification or consent execution logic end to end
- Renaming every provider-related term in the product outside the in-scope shared connection and target-scope hotspot
- Introducing a new governed-subject taxonomy or compare orchestration layer
### Assumptions
- Current-release product truth remains Microsoft-first, but shared provider connection semantics must no longer imply that Microsoft identity is the default platform contract.
- Existing provider connection resources, onboarding steps, consent flows, and verification flows remain in place and are narrowed rather than rebuilt.
- Existing operator roles and capabilities already cover the in-scope provider connection surfaces.
- Legacy Microsoft-shaped fields may still exist on some rows, but this spec prefers canonical replacement on in-scope shared surfaces instead of compatibility shims.
### Dependencies
- Boundary ownership classification from `specs/237-provider-boundary-hardening/spec.md`
- Existing provider connection resource and detail surfaces
- Existing onboarding, consent, and verification flows that display provider connection identity or scope
- Existing audit logging and authorization infrastructure for provider connections
## 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 |
|---|---|---|---|---|---|---|---|---|---|---|
| Provider connection list | existing provider connection Filament resource | `New connection` | `recordUrl()` to detail | `Edit`, `More` | Existing grouped bulk actions only where already supported | `Connect provider` | `Edit`, `Retry consent`, `Run verification`, `More` | `Save` and `Cancel` on create and edit pages | yes | Default labels become provider and target-scope neutral; no exemption |
| Provider connection detail | existing provider connection detail surface | `Edit`, `Retry consent`, `Run verification` | detail page is the only primary inspect model | none added | none | n/a | `Edit`, `Retry consent`, `Run verification`, `More` | `Save` and `Cancel` on edit page | yes | Provider-specific Microsoft identifiers remain contextual secondary detail |
| Tenant onboarding provider step | existing tenant onboarding provider setup step | none added | existing wizard step only | none | none | existing provider setup CTA remains single primary CTA | contextual open-setup action only where already supported | existing continue and cancel flow remains | yes | Step remains native; only shared target-scope semantics are narrowed |
### Key Entities *(include if feature involves data)*
- **Provider Connection**: The tenant-owned record that represents a provider-backed connection and its readiness for use.
- **Target Scope**: The neutral platform description of what the connection points to, expressed through shared fields such as scope kind, scope identifier, and scope display name.
- **Provider-Specific Identity Metadata**: Contextual provider-owned detail such as Microsoft tenant or directory identifiers that operators may need for consent, verification, or troubleshooting.
- **Connection Readiness Summary**: The operator-facing combination of consent state and verification state used to explain whether the connection is usable.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-238-001**: In covered provider connection list, detail, create, edit, and onboarding surfaces, 100% of default shared labels use neutral provider and target-scope language rather than Microsoft-specific defaults.
- **SC-238-002**: In covered Microsoft scenarios, 100% of required tenant or directory detail remains available contextually without becoming the default shared identity language of the connection.
- **SC-238-003**: In focused operator-visible coverage, users can distinguish provider, target scope, consent state, and verification state without relying on raw provider-specific identifiers to infer meaning.
- **SC-238-004**: In all covered unsupported or missing-context scenarios, shared validation paths fail explicitly rather than inheriting Microsoft-first fallback behavior.
- **SC-238-005**: In all covered create, update, and security-sensitive mutation scenarios, the audit trail records workspace, tenant, provider, and target-scope context.
- **SC-238-006**: Focused guard or regression coverage fails when shared provider/platform seams introduce Microsoft-specific default labels, filters, validation messages, audit prose, or helper copy outside explicitly provider-owned Microsoft paths.

View File

@ -0,0 +1,258 @@
---
description: "Task list for Provider Identity & Target Scope Neutrality"
---
# Tasks: Provider Identity & Target Scope Neutrality
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/contracts/`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/238-provider-identity-target-scope/quickstart.md`
**Tests**: REQUIRED (Pest) for runtime behavior changes; keep proof in the narrow `Unit` and `Feature` lanes named in the plan
**Operations**: No new `OperationRun` type or Monitoring surface is introduced; preserve current health-check and verification run behavior while neutralizing shared target-scope semantics
**RBAC**: Preserve existing workspace and tenant authorization semantics on touched provider connection and onboarding surfaces, including `404` for non-members and `403` for members missing capability
**Provider Boundary**: Shared target-scope truth remains platform-core; Microsoft tenant, directory, consent, and authority details remain explicitly bounded provider-owned contextual metadata
**Organization**: Tasks are grouped by user story so the shared target-scope contract, shared-surface adoption, and guardrail close-out can be implemented and validated incrementally.
## Test Governance Checklist
- [X] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
- [X] New or changed tests stay in existing provider connection, Filament, audit, onboarding, and guard families; no browser or heavy-governance lane is added.
- [X] Shared helpers, factories, fixtures, and provider context defaults stay cheap by default; do not introduce a default provider-world bootstrap.
- [X] Planned validation commands cover descriptor normalization, explicit unsupported-path handling, shared-surface neutrality, unchanged auth and confirmation behavior, onboarding reuse, audit wording, and guardrails without widening scope.
- [X] Surface test profile remains `standard-native-filament` with one onboarding wizard-step extension.
- [X] Any remaining provider-specific hotspot resolves as `document-in-feature` or `follow-up-spec`, not as silent platform-core truth.
## Phase 1: Setup (Shared Context)
**Purpose**: Confirm the current hotspots, owning files, and existing proof lanes before introducing the shared target-scope contract.
- [X] T001 Review the current Microsoft-shaped shared-surface hotspots in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolution.php`, and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [X] T002 [P] Review existing provider connection audit and UI enforcement coverage in `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
- [X] T003 [P] Review existing provider-owned exception seams in `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Add the shared target-scope primitives that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Create the shared descriptor primitives in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeDescriptor.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderIdentityContextMetadata.php`
- [X] T005 [P] Create the shared normalization and surface-summary helpers in `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
- [X] T006 Update `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `apps/platform/app/Services/Providers/ProviderIdentityResolution.php` to expose neutral target-scope data plus provider-owned contextual metadata
- [X] T007 [P] Align resolver consumption of the new neutral descriptor in `apps/platform/app/Services/Providers/ProviderConnectionResolver.php`, `apps/platform/app/Services/Providers/ProviderIdentityResolver.php`, and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`
- [X] T008 [P] Sync the shared descriptor, normalization, surface-summary, and neutrality-evaluation shapes with `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml`
**Checkpoint**: Shared target-scope primitives exist; user story work can now build on one explicit neutral contract.
---
## Phase 3: User Story 1 - Choose the Right Target Scope (Priority: P1)
**Goal**: Make the create and edit flow describe provider connection scope in neutral platform language before the operator saves it.
**Independent Test**: Open the provider connection create or edit flow, choose an in-scope provider, and verify the shared fields and helper copy use neutral target-scope language while Microsoft-specific identity only appears contextually when the selected provider is Microsoft.
### Tests for User Story 1
- [X] T009 [P] [US1] Add descriptor normalization coverage, including explicit unsupported provider or target-scope combinations and missing-context failures, in `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`
- [X] T010 [P] [US1] Add create and edit neutral target-scope flow coverage, including explicit unsupported combination and missing-context failures, in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`
- [X] T011 [P] [US1] Extend shared authorization and UI enforcement coverage for provider connection create, list, and detail target-scope surfaces, including unchanged `404` versus `403` behavior, in `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
### Implementation for User Story 1
- [X] T012 [US1] Replace shared create and edit form fields plus helper text with neutral target-scope wording in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
- [X] T013 [US1] Route create and edit normalization plus mutation messaging through the shared target-scope helper in `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php`
- [X] T014 [US1] Keep Microsoft-specific contextual identity available only on Microsoft create and edit paths in `apps/platform/app/Services/Providers/ProviderIdentityResolver.php` and `apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php`
- [X] T015 [US1] Run the US1 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
**Checkpoint**: User Story 1 is independently deliverable as the core setup-flow neutrality slice.
---
## Phase 4: User Story 2 - Inspect Connection Meaning Without Guesswork (Priority: P1)
**Goal**: Make list and detail surfaces show provider, target scope, consent state, and verification state separately without forcing operators to infer meaning from raw Microsoft identifiers.
**Independent Test**: Load provider connection list and detail surfaces for representative connections and verify the default-visible summary stays neutral while provider-specific identity remains secondary diagnostic detail.
### Tests for User Story 2
- [X] T016 [P] [US2] Add list and detail neutrality coverage plus default-visible status separation in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`
- [X] T017 [P] [US2] Add shared identity-resolution neutrality coverage in `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`
- [X] T018 [P] [US2] Extend badge and summary assertions that keep consent and verification distinct in `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php` and `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`
### Implementation for User Story 2
- [X] T019 [US2] Replace default list, table, infolist, and detail target-scope labels plus summaries in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
- [X] T020 [P] [US2] Align derived connection summaries with the shared target-scope descriptor in `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php`
- [X] T021 [US2] Keep contextual Microsoft identity and diagnostics secondary to neutral target-scope truth in `apps/platform/app/Services/Providers/ProviderConnectionResolution.php` and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
- [X] T022 [US2] Run the US2 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`, `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`, and `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`
**Checkpoint**: User Stories 1 and 2 both work independently, with shared setup and inspection surfaces aligned to one neutral target-scope contract.
---
## Phase 5: User Story 3 - Extend Shared Provider Surfaces Safely (Priority: P2)
**Goal**: Reuse the same neutral target-scope contract in onboarding, mutation copy, and audit wording, and block future regressions on shared provider surfaces.
**Independent Test**: Exercise onboarding, audit, UI enforcement, and guard coverage to verify shared provider surfaces cannot reintroduce Microsoft-specific default labels, filters, required fields, validation messages, helper copy, or audit prose without explicit provider-owned justification.
### Tests for User Story 3
- [X] T023 [P] [US3] Add onboarding provider-setup neutrality coverage plus unchanged `404` versus `403` leakage protection in `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`
- [X] T024 [P] [US3] Extend shared audit wording coverage and sensitive-action confirmation-gate coverage in `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php` and `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`
- [X] T025 [P] [US3] Add shared-surface guard coverage for Microsoft-specific default labels, filters, required fields, validation messages, helper copy, and audit prose in `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
### Implementation for User Story 3
- [X] T026 [US3] Reuse the shared target-scope descriptor in the onboarding provider setup step in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
- [X] T027 [US3] Align provider connection action labels, filter labels, section headings, helper copy, validation messages, mutation messaging, and audit wording with neutral provider and target-scope vocabulary in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php`
- [X] T028 [US3] Codify the provider-owned contextual exception boundary from `specs/237-provider-boundary-hardening/spec.md` and the review-stop expectations in `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml` and `specs/238-provider-identity-target-scope/quickstart.md`
- [X] T029 [US3] Run the US3 proof lane documented in `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
**Checkpoint**: All user stories are independently functional, and shared provider surfaces are guarded against reintroducing Microsoft-first default truth.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Finalize formatting, validation, and guardrail close-out across the full slice.
- [X] T030 [P] Refresh the implementation notes, logical contract wording, and validation commands in `specs/238-provider-identity-target-scope/plan.md`, `specs/238-provider-identity-target-scope/quickstart.md`, and `specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml`
- [X] T031 [P] Run formatting for touched PHP files in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Services/Providers/`, `apps/platform/app/Support/Providers/TargetScope/`, `apps/platform/tests/Unit/Providers/`, and `apps/platform/tests/Feature/`
- [X] T032 Run the final focused validation lane from `specs/238-provider-identity-target-scope/quickstart.md` against `apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php`, `apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php`, `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`, `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`, `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`
- [X] T033 Record the guardrail close-out, `document-in-feature` disposition, and deferred provider-boundary follow-up status in `specs/238-provider-identity-target-scope/plan.md` and `specs/238-provider-identity-target-scope/quickstart.md`
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow the stable descriptor contract from User Story 1 on shared resource surfaces.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and should follow User Story 1 because onboarding and audit wording reuse the same target-scope descriptor and mutation vocabulary.
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
### User Story Dependencies
- **User Story 1 (P1)**: This is the MVP and should ship first.
- **User Story 2 (P1)**: Conceptually independent after Phase 2, but it reuses the target-scope descriptor and shared resource semantics stabilized in User Story 1.
- **User Story 3 (P2)**: Conceptually independent after Phase 2, but its onboarding, audit, confirmation-gate, exception-boundary, and guardrail tasks remain part of the shippable slice because they satisfy FR-238-011, FR-238-014, FR-238-015, and FR-238-016.
### Within Each User Story
- Tests should be written and fail before the corresponding implementation tasks.
- Shared descriptor and resolution changes must land before any surface-specific adoption task consumes them.
- Serialize edits in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` when multiple story tasks touch the same resource file.
- Finish each story's verification task before moving to the next priority when working sequentially.
### Parallel Opportunities
- **Setup**: `T002` and `T003` can run in parallel.
- **Foundational**: `T005`, `T007`, and `T008` can run in parallel after `T004` defines the descriptor primitives.
- **US1 tests**: `T009`, `T010`, and `T011` can run in parallel.
- **US1 implementation**: `T013` and `T014` can run in parallel after `T012` settles the shared form contract.
- **US2 tests**: `T016`, `T017`, and `T018` can run in parallel.
- **US2 implementation**: `T020` can run in parallel with `T021`, but both should follow `T019` because `ProviderConnectionResource.php` is shared.
- **US3 tests**: `T023`, `T024`, and `T025` can run in parallel.
- **US3 implementation**: `T026` and `T028` can run in parallel; `T027` should serialize after `T019` and `T012` because it touches `ProviderConnectionResource.php`.
- **Polish**: `T030` and `T031` can run in parallel before `T032` and `T033`.
---
## Parallel Example: User Story 1
```bash
# Run US1 coverage in parallel:
T009 apps/platform/tests/Unit/Providers/ProviderConnectionTargetScopeDescriptorTest.php
T010 apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php
T011 apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php
# Then split the non-overlapping implementation follow-up:
T013 apps/platform/app/Services/Providers/ProviderConnectionMutationService.php and apps/platform/app/Support/Providers/TargetScope/ProviderConnectionTargetScopeNormalizer.php
T014 apps/platform/app/Services/Providers/ProviderIdentityResolver.php and apps/platform/app/Services/Providers/PlatformProviderIdentityResolver.php
```
---
## Parallel Example: User Story 2
```bash
# Run US2 summary and identity coverage in parallel:
T016 apps/platform/tests/Feature/ProviderConnections/ProviderConnectionNeutralitySpec238Test.php and apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php
T017 apps/platform/tests/Unit/Providers/ProviderIdentityResolutionNeutralityTest.php
T018 apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php and apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php
# Then split non-overlapping implementation follow-up:
T020 apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php and apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php
T021 apps/platform/app/Services/Providers/ProviderConnectionResolution.php
```
---
## Parallel Example: User Story 3
```bash
# Run US3 onboarding, audit, and guard coverage in parallel:
T023 apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php
T024 apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php
T025 apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php
# Then split non-overlapping implementation follow-up:
T026 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
T028 specs/238-provider-identity-target-scope/contracts/provider-identity-target-scope.logical.openapi.yaml and specs/238-provider-identity-target-scope/quickstart.md
```
---
## Implementation Strategy
### MVP / Shippable Slice
1. Complete Phase 1: Setup.
2. Complete Phase 2: Foundational.
3. Complete Phase 3: User Story 1 and Phase 4: User Story 2.
4. Complete all of Phase 5: User Story 3 so onboarding, audit wording, confirmation-gate proof, exception-boundary codification, and guard coverage land with the same shippable slice.
5. Stop and validate with `T015`, `T022`, `T029`, and `T032`.
6. Review whether list, detail, create, edit, onboarding, audit wording, and guardrails now expose one stable target-scope contract before moving to close-out only work.
### Incremental Delivery
1. Setup and Foundational establish the shared target-scope descriptor and neutral resolution path.
2. Add User Story 1 and validate create and edit neutrality.
3. Add User Story 2 and validate inspection, summary, and status separation parity.
4. Add User Story 3 and validate onboarding reuse, audit wording, and guardrail protection.
5. Finish with formatting, final validation, and close-out documentation.
### Parallel Team Strategy
With multiple developers:
1. Complete Setup and Foundational together.
2. After Phase 2:
- Developer A: create and edit neutrality in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` plus supporting mutation wiring.
- Developer B: list and detail summary alignment in `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` and the new target-scope support files.
- Developer C: onboarding, audit wording, and guardrails in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionIdentityAuditTest.php`, and `apps/platform/tests/Feature/Guards/ProviderConnectionNeutralityGuardTest.php`.
3. Serialize edits in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` because User Stories 1, 2, and 3 all touch that file.
### Suggested MVP Scope
The narrowest shippable increment is Phase 1 through Phase 5. Phase 6 is close-out, formatting, and final documentation refresh.
---
## Notes
- `[P]` marks tasks that can run in parallel once their prerequisites are satisfied and the files do not overlap.
- `[US1]`, `[US2]`, and `[US3]` map directly to the user stories in `spec.md`.
- The narrowest proving lane remains `fast-feedback` plus `confidence`; do not widen into browser or heavy-governance without explicit follow-up justification.
- Keep Microsoft-specific identity contextual and bounded to provider-owned surfaces; do not turn this slice into a second-provider or credential-model redesign.