Compare commits
2 Commits
236-canoni
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bd26e209de | |||
| 6a5b8a3a11 |
@ -1,4 +1,4 @@
|
|||||||
[mcp_servers.laravel-boost]
|
[mcp_servers.laravel-boost]
|
||||||
command = "vendor/bin/sail"
|
command = "./scripts/platform-sail"
|
||||||
args = ["artisan", "boost:mcp"]
|
args = ["artisan", "boost:mcp"]
|
||||||
cwd = "/Users/ahmeddarrazi/Documents/projects/TenantAtlas"
|
cwd = "/Users/ahmeddarrazi/Documents/projects/wt-plattform"
|
||||||
|
|||||||
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -248,6 +248,10 @@ ## Active Technologies
|
|||||||
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
|
- Existing PostgreSQL `baseline_profiles` and `tenants` tables; no new persistence and no schema migration in this slice (234-dead-transitional-residue)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `BaselineCaptureService`, `CaptureBaselineSnapshotJob`, `BaselineReasonCodes`, `BaselineCompareStats`, `ReasonTranslator`, `GovernanceRunDiagnosticSummaryBuilder`, `OperationRunService`, `BaselineProfile`, `BaselineSnapshot`, `OperationRunOutcome`, existing Filament capture/compare surfaces (235-baseline-capture-truth)
|
||||||
- Existing PostgreSQL tables only; no new table or schema migration is planned in the mainline slice (235-baseline-capture-truth)
|
- 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -282,9 +286,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 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
|
- 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
|
|
||||||
- 233-stale-run-visibility: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament widgets/resources/pages, Pest v4, `App\Models\OperationRun`, `App\Support\Operations\OperationRunFreshnessState`, `App\Services\Operations\OperationLifecycleReconciler`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\OpsUx\ActiveRuns`, `App\Support\Badges\BadgeCatalog` / `BadgeRenderer`, `App\Support\Workspaces\WorkspaceOverviewBuilder`, `App\Support\OperationRunLinks`
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
@ -1,28 +1,30 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.8.0 -> 2.9.0
|
- Version change: 2.9.0 -> 2.10.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- Added provider-boundary guardrail set under First Provider Is Not
|
- Expanded Operations / Run Observability Standard so OperationRun
|
||||||
Platform Core (PROV-001 with sub-rules PROV-002 through PROV-005)
|
start UX is shared-contract-owned instead of surface-owned
|
||||||
- Expanded Governance review expectations for provider-owned vs
|
- Expanded Governance review expectations for OperationRun-starting
|
||||||
platform-core boundaries
|
features, explicit queued-notification policy, and bounded
|
||||||
|
exceptions
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- First Provider Is Not Platform Core (PROV-001): keeps Microsoft as
|
- OperationRun Start UX Contract (OPS-UX-START-001): centralizes
|
||||||
the current first provider without allowing provider-specific
|
queued toast/link/event/message semantics, run/artifact deep links,
|
||||||
semantics to silently become platform-core truth; requires explicit
|
queued DB-notification policy, and tenant/workspace-safe operation
|
||||||
review of provider-owned vs platform-core seams and prefers bounded
|
URL resolution behind one shared OperationRun UX layer
|
||||||
extraction over speculative multi-provider frameworks
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: add provider-boundary platform
|
- .specify/templates/spec-template.md: add OperationRun UX Impact
|
||||||
core check ✅
|
section + start-contract prompts ✅
|
||||||
- .specify/templates/plan-template.md: add provider-boundary planning
|
- .specify/templates/plan-template.md: add OperationRun UX Impact
|
||||||
fields + constitution check ✅
|
planning section + constitution checks ✅
|
||||||
- .specify/templates/tasks-template.md: add provider-boundary task
|
- .specify/templates/tasks-template.md: add central start-UX reuse,
|
||||||
requirements ✅
|
queued-notification policy, and exception tasks ✅
|
||||||
- .specify/templates/checklist-template.md: add provider-boundary
|
- .specify/templates/checklist-template.md: add OperationRun start
|
||||||
review checks ✅
|
UX review checks ✅
|
||||||
|
- docs/product/standards/README.md: refresh constitution index for
|
||||||
|
the new ops-UX contract ✅
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
- N/A `.specify/templates/commands/*.md` directory is not present
|
- N/A `.specify/templates/commands/*.md` directory is not present
|
||||||
- Follow-up TODOs: None
|
- Follow-up TODOs: None
|
||||||
@ -307,24 +309,57 @@ ### Operations / Run Observability Standard
|
|||||||
even if implemented by multiple jobs/steps (“umbrella run”).
|
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.
|
- “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).
|
- 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,
|
- Start surfaces MUST NOT perform remote work inline and MUST NOT compose OperationRun start UX locally; they only:
|
||||||
confirm + “View run”.
|
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)
|
### 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)
|
1) Toast (intent only / queued-only)
|
||||||
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
|
- A toast MAY be shown only when the run is accepted/queued (intent feedback).
|
||||||
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
|
- The toast MUST use `OperationUxPresenter::queuedToast($operationType)->send()`.
|
||||||
- Feature code MUST NOT craft ad-hoc operation toasts.
|
- Feature code MUST NOT craft ad-hoc operation toasts.
|
||||||
- A dedicated dedupe message MUST use the presenter (e.g., `alreadyQueuedToast(...)`), not `Notification::make()`.
|
- 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)
|
2) Progress (active awareness only)
|
||||||
- Live progress MUST exist only in:
|
- Live progress MUST exist only in:
|
||||||
- the global active-ops widget, and
|
- the global active-ops widget, and
|
||||||
- Monitoring → Operation Run Detail.
|
- Monitoring → Operation Run Detail.
|
||||||
- These surfaces MUST show only active runs (`queued|running`) and MUST never show terminal runs.
|
- 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 is allowed ONLY when `summary_counts.total` and `summary_counts.processed` are valid numeric values.
|
||||||
- Determinate progress MUST be clamped to 0–100. Otherwise render indeterminate + elapsed time.
|
- Determinate progress MUST be clamped to 0–100. Otherwise render indeterminate + elapsed time.
|
||||||
- The widget MUST NOT show percentage text (optional `processed/total` is allowed).
|
- 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:
|
The repo MUST include automated guards (Pest) that fail CI if:
|
||||||
- any direct `OperationRun` status/outcome transition occurs outside `OperationRunService`,
|
- 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),
|
- jobs emit DB notifications for operation completion/abort (`OperationRunCompleted` is the single terminal notification),
|
||||||
- deprecated legacy operation notification classes are referenced again.
|
- 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.
|
- Runtime-changing or test-affecting specs and PRs MUST include testing/lane/runtime impact covering actual test-purpose classification, affected lanes, fixture/helper/factory/seed/context cost changes, any heavy-family expansion, expected budget/baseline/trend effect, escalation decisions, and the minimal validation commands.
|
||||||
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
|
- Specs, plans, task lists, and review checklists MUST surface the test-governance questions needed to catch lane drift, hidden defaults, and runtime-cost escalation before merge.
|
||||||
- Specs and PRs that touch shared provider/platform seams MUST classify the touched boundary as provider-owned or platform-core, keep provider-specific semantics out of platform-core contracts and vocabulary unless explicitly justified, and record whether any remaining hotspot is resolved in-feature or escalated as a follow-up spec.
|
- Specs and PRs that 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
|
- Specs and PRs that change operator-facing surfaces MUST classify each
|
||||||
affected surface under DECIDE-001 and justify any new Primary
|
affected surface under DECIDE-001 and justify any new Primary
|
||||||
Decision Surface or workflow-first navigation change.
|
Decision Surface or workflow-first navigation change.
|
||||||
@ -1631,4 +1675,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 2.9.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-23
|
**Version**: 2.10.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-04-24
|
||||||
|
|||||||
@ -40,9 +40,13 @@ mkdir -p "$FEATURE_DIR"
|
|||||||
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
TEMPLATE="$REPO_ROOT/.specify/templates/plan-template.md"
|
||||||
if [[ -f "$TEMPLATE" ]]; then
|
if [[ -f "$TEMPLATE" ]]; then
|
||||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||||
|
if ! $JSON_MODE; then
|
||||||
echo "Copied plan template to $IMPL_PLAN"
|
echo "Copied plan template to $IMPL_PLAN"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
|
if ! $JSON_MODE; then
|
||||||
echo "Warning: Plan template not found at $TEMPLATE"
|
echo "Warning: Plan template not found at $TEMPLATE"
|
||||||
|
fi
|
||||||
# Create a basic plan file if template doesn't exist
|
# Create a basic plan file if template doesn't exist
|
||||||
touch "$IMPL_PLAN"
|
touch "$IMPL_PLAN"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -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.
|
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
|
||||||
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
|
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- [ ] 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.
|
||||||
|
|||||||
@ -54,6 +54,18 @@ ## Shared Pattern & System Fit
|
|||||||
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
|
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
|
||||||
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
|
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
|
||||||
|
|
||||||
|
## 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
|
## 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`.**
|
> **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)
|
- 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
|
- 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`
|
- 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 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 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
|
- Ops-UX guards: CI has regression guards that fail with actionable output (file + snippet) when these patterns regress
|
||||||
|
|||||||
@ -47,6 +47,16 @@ ## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches not
|
|||||||
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
|
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
|
||||||
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
|
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
|
||||||
|
|
||||||
|
## 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`)*
|
## 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]
|
- **Shared provider/platform boundary touched?**: [yes/no]
|
||||||
@ -263,12 +273,21 @@ ## Requirements *(mandatory)*
|
|||||||
- and the exact minimal validation commands reviewers should run.
|
- and the exact minimal validation commands reviewers should run.
|
||||||
|
|
||||||
**Constitution alignment (OPS-UX):** If this feature creates/reuses an `OperationRun`, the spec MUST:
|
**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`),
|
- 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,
|
- 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),
|
- 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.
|
- 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:
|
**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`),
|
- 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),
|
- ensure any cross-plane access is deny-as-not-found (404),
|
||||||
|
|||||||
@ -18,17 +18,22 @@ # Tasks: [FEATURE NAME]
|
|||||||
- record budget, baseline, or trend follow-up when runtime cost shifts materially,
|
- record budget, baseline, or trend follow-up when runtime cost shifts materially,
|
||||||
- and document whether the change resolves as `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
- 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
|
**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).
|
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
|
Auth handshake exception (OPS-EX-AUTH-001): OIDC/SAML login handshakes may perform synchronous outbound HTTP on `/auth/*` endpoints
|
||||||
without an `OperationRun`.
|
without an `OperationRun`.
|
||||||
If this feature creates/reuses an `OperationRun`, tasks MUST also include:
|
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),
|
- 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 `OperationRun.status` / `OperationRun.outcome` transitions happen only via `OperationRunService`,
|
||||||
- ensuring `summary_counts` keys come from `OperationSummaryKeys::all()` and values are flat numeric-only,
|
- 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,
|
- 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:
|
**RBAC**: If this feature introduces or changes authorization, tasks MUST include:
|
||||||
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
- explicit Gate/Policy enforcement for all mutation endpoints/actions,
|
||||||
- explicit 404 vs 403 semantics:
|
- explicit 404 vs 403 semantics:
|
||||||
|
|||||||
@ -45,4 +45,17 @@ public function tenant(): BelongsTo
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function canonicalControlReferences(): array
|
||||||
|
{
|
||||||
|
$payload = is_array($this->summary_payload) ? $this->summary_payload : [];
|
||||||
|
$references = $payload['canonical_controls'] ?? [];
|
||||||
|
|
||||||
|
return is_array($references)
|
||||||
|
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,4 +192,17 @@ public function publishBlockers(): array
|
|||||||
|
|
||||||
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
|
return is_array($blockers) ? array_values(array_map('strval', $blockers)) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function canonicalControlReferences(): array
|
||||||
|
{
|
||||||
|
$summary = is_array($this->summary) ? $this->summary : [];
|
||||||
|
$references = $summary['canonical_controls'] ?? [];
|
||||||
|
|
||||||
|
return is_array($references)
|
||||||
|
? array_values(array_filter($references, static fn (mixed $reference): bool => is_array($reference)))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -219,6 +219,9 @@ public function buildSnapshotPayload(Tenant $tenant): array
|
|||||||
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
|
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
|
||||||
? $findingsSummary['report_bucket_counts']
|
? $findingsSummary['report_bucket_counts']
|
||||||
: [],
|
: [],
|
||||||
|
'canonical_controls' => is_array($findingsSummary['canonical_controls'] ?? null)
|
||||||
|
? $findingsSummary['canonical_controls']
|
||||||
|
: [],
|
||||||
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
||||||
? $findingsSummary['risk_acceptance']
|
? $findingsSummary['risk_acceptance']
|
||||||
: [
|
: [
|
||||||
|
|||||||
@ -10,12 +10,15 @@
|
|||||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
use App\Support\Findings\FindingOutcomeSemantics;
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlResolver;
|
||||||
|
|
||||||
final class FindingsSummarySource implements EvidenceSourceProvider
|
final class FindingsSummarySource implements EvidenceSourceProvider
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
||||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||||
|
private readonly CanonicalControlResolver $canonicalControlResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function key(): string
|
public function key(): string
|
||||||
@ -36,6 +39,7 @@ public function collect(Tenant $tenant): array
|
|||||||
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
|
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
|
||||||
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
|
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
|
||||||
$outcome = $this->findingOutcomeSemantics->describe($finding);
|
$outcome = $this->findingOutcomeSemantics->describe($finding);
|
||||||
|
$canonicalControlResolution = $this->canonicalControlResolutionFor($finding);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => (int) $finding->getKey(),
|
'id' => (int) $finding->getKey(),
|
||||||
@ -57,6 +61,7 @@ public function collect(Tenant $tenant): array
|
|||||||
'report_bucket' => $outcome['report_bucket'],
|
'report_bucket' => $outcome['report_bucket'],
|
||||||
'governance_state' => $governanceState,
|
'governance_state' => $governanceState,
|
||||||
] : null,
|
] : null,
|
||||||
|
'canonical_control_resolution' => $canonicalControlResolution,
|
||||||
'governance_state' => $governanceState,
|
'governance_state' => $governanceState,
|
||||||
'governance_warning' => $governanceWarning,
|
'governance_warning' => $governanceWarning,
|
||||||
];
|
];
|
||||||
@ -81,6 +86,12 @@ public function collect(Tenant $tenant): array
|
|||||||
$reportBucketCounts[$reportBucket]++;
|
$reportBucketCounts[$reportBucket]++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$canonicalControls = $entries
|
||||||
|
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
|
||||||
|
->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null))
|
||||||
|
->unique(static fn (array $control): string => (string) $control['control_key'])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
$riskAcceptedEntries = $entries->filter(
|
$riskAcceptedEntries = $entries->filter(
|
||||||
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
|
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
|
||||||
@ -115,6 +126,7 @@ public function collect(Tenant $tenant): array
|
|||||||
],
|
],
|
||||||
'outcome_counts' => $outcomeCounts,
|
'outcome_counts' => $outcomeCounts,
|
||||||
'report_bucket_counts' => $reportBucketCounts,
|
'report_bucket_counts' => $reportBucketCounts,
|
||||||
|
'canonical_controls' => $canonicalControls,
|
||||||
'entries' => $entries->all(),
|
'entries' => $entries->all(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -133,4 +145,68 @@ public function collect(Tenant $tenant): array
|
|||||||
'sort_order' => 10,
|
'sort_order' => 10,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function canonicalControlResolutionFor(Finding $finding): array
|
||||||
|
{
|
||||||
|
return $this->canonicalControlResolver
|
||||||
|
->resolve($this->resolutionRequestFor($finding))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolutionRequestFor(Finding $finding): CanonicalControlResolutionRequest
|
||||||
|
{
|
||||||
|
$evidence = is_array($finding->evidence_jsonb) ? $finding->evidence_jsonb : [];
|
||||||
|
$findingType = (string) $finding->finding_type;
|
||||||
|
|
||||||
|
if ($findingType === Finding::FINDING_TYPE_PERMISSION_POSTURE) {
|
||||||
|
return new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'permission_posture',
|
||||||
|
workload: 'entra',
|
||||||
|
signalKey: 'permission_posture.required_graph_permission',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($findingType === Finding::FINDING_TYPE_ENTRA_ADMIN_ROLES) {
|
||||||
|
$roleTemplateId = (string) ($evidence['role_template_id'] ?? '');
|
||||||
|
|
||||||
|
return new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'entra_admin_roles',
|
||||||
|
workload: 'entra',
|
||||||
|
signalKey: $roleTemplateId === '62e90394-69f5-4237-9190-012177145e10'
|
||||||
|
? 'entra_admin_roles.global_admin_assignment'
|
||||||
|
: 'entra_admin_roles.privileged_role_assignment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($findingType === Finding::FINDING_TYPE_DRIFT) {
|
||||||
|
$policyType = is_string($evidence['policy_type'] ?? null) && trim((string) $evidence['policy_type']) !== ''
|
||||||
|
? trim((string) $evidence['policy_type'])
|
||||||
|
: 'drift';
|
||||||
|
|
||||||
|
return new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: $policyType,
|
||||||
|
workload: 'intune',
|
||||||
|
signalKey: match ($policyType) {
|
||||||
|
'deviceCompliancePolicy' => 'intune.device_compliance_policy',
|
||||||
|
'drift' => 'finding.drift',
|
||||||
|
default => 'intune.device_configuration_drift',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: $findingType,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@
|
|||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
final class ProviderGateway
|
final class ProviderGateway
|
||||||
{
|
{
|
||||||
@ -53,6 +55,17 @@ public function request(ProviderConnection $connection, string $method, string $
|
|||||||
*/
|
*/
|
||||||
public function graphOptions(ProviderConnection $connection, array $overrides = []): array
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
final class ProviderIdentityResolution
|
final class ProviderIdentityResolution
|
||||||
{
|
{
|
||||||
@ -66,24 +64,6 @@ public static function blocked(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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
|
public function effectiveReasonCode(): string
|
||||||
{
|
{
|
||||||
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
return $this->reasonCode ?? ProviderReasonCodes::UnknownError;
|
||||||
|
|||||||
@ -7,44 +7,48 @@
|
|||||||
|
|
||||||
final class ProviderOperationRegistry
|
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 [
|
return [
|
||||||
'provider.connection.check' => [
|
'provider.connection.check' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'provider.connection.check',
|
||||||
'module' => 'health_check',
|
'module' => 'health_check',
|
||||||
'label' => 'Provider connection check',
|
'label' => 'Provider connection check',
|
||||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'inventory_sync' => [
|
'inventory_sync' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'inventory_sync',
|
||||||
'module' => 'inventory',
|
'module' => 'inventory',
|
||||||
'label' => 'Inventory sync',
|
'label' => 'Inventory sync',
|
||||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'compliance.snapshot' => [
|
'compliance.snapshot' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'compliance.snapshot',
|
||||||
'module' => 'compliance',
|
'module' => 'compliance',
|
||||||
'label' => 'Compliance snapshot',
|
'label' => 'Compliance snapshot',
|
||||||
'required_capability' => Capabilities::PROVIDER_RUN,
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'restore.execute' => [
|
'restore.execute' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'restore.execute',
|
||||||
'module' => 'restore',
|
'module' => 'restore',
|
||||||
'label' => 'Restore execution',
|
'label' => 'Restore execution',
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
'entra_group_sync' => [
|
'entra_group_sync' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'entra_group_sync',
|
||||||
'module' => 'directory_groups',
|
'module' => 'directory_groups',
|
||||||
'label' => 'Directory groups sync',
|
'label' => 'Directory groups sync',
|
||||||
'required_capability' => Capabilities::TENANT_SYNC,
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
],
|
],
|
||||||
'directory_role_definitions.sync' => [
|
'directory_role_definitions.sync' => [
|
||||||
'provider' => 'microsoft',
|
'operation_type' => 'directory_role_definitions.sync',
|
||||||
'module' => 'directory_role_definitions',
|
'module' => 'directory_role_definitions',
|
||||||
'label' => 'Role definitions sync',
|
'label' => 'Role definitions sync',
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'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
|
public function get(string $operationType): array
|
||||||
{
|
{
|
||||||
$operationType = trim($operationType);
|
$operationType = trim($operationType);
|
||||||
|
|
||||||
$definition = $this->all()[$operationType] ?? null;
|
$definition = $this->definitions()[$operationType] ?? null;
|
||||||
|
|
||||||
if (! is_array($definition)) {
|
if (! is_array($definition)) {
|
||||||
throw new InvalidArgumentException("Unknown provider operation type: {$operationType}");
|
throw new InvalidArgumentException("Unknown provider operation type: {$operationType}");
|
||||||
@ -72,4 +135,85 @@ public function get(string $operationType): array
|
|||||||
|
|
||||||
return $definition;
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,26 +42,47 @@ public function start(
|
|||||||
array $extraContext = [],
|
array $extraContext = [],
|
||||||
): ProviderOperationStartResult {
|
): ProviderOperationStartResult {
|
||||||
$definition = $this->registry->get($operationType);
|
$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
|
$resolution = $connection instanceof ProviderConnection
|
||||||
? $this->resolver->validateConnection($tenant, (string) $definition['provider'], $connection)
|
? $this->resolver->validateConnection($tenant, (string) $binding['provider'], $connection)
|
||||||
: $this->resolver->resolveDefault($tenant, (string) $definition['provider']);
|
: $this->resolver->resolveDefault($tenant, (string) $binding['provider']);
|
||||||
|
|
||||||
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
|
if (! $resolution->resolved || ! $resolution->connection instanceof ProviderConnection) {
|
||||||
return $this->startBlocked(
|
return $this->startBlocked(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
operationType: $operationType,
|
operationType: $operationType,
|
||||||
provider: (string) $definition['provider'],
|
provider: (string) $binding['provider'],
|
||||||
module: (string) $definition['module'],
|
module: (string) $definition['module'],
|
||||||
reasonCode: $resolution->effectiveReasonCode(),
|
reasonCode: $resolution->effectiveReasonCode(),
|
||||||
extensionReasonCode: $resolution->extensionReasonCode,
|
extensionReasonCode: $resolution->extensionReasonCode,
|
||||||
reasonMessage: $resolution->message,
|
reasonMessage: $resolution->message,
|
||||||
connection: $resolution->connection ?? $connection,
|
connection: $resolution->connection ?? $connection,
|
||||||
initiator: $initiator,
|
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;
|
$connection = $resolution->connection;
|
||||||
|
|
||||||
if (! $connection instanceof ProviderConnection) {
|
if (! $connection instanceof ProviderConnection) {
|
||||||
@ -114,6 +135,7 @@ public function start(
|
|||||||
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext),
|
||||||
'provider' => $lockedConnection->provider,
|
'provider' => $lockedConnection->provider,
|
||||||
'module' => $definition['module'],
|
'module' => $definition['module'],
|
||||||
|
'provider_binding' => $this->bindingContext($binding),
|
||||||
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
||||||
'target_scope' => [
|
'target_scope' => [
|
||||||
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
|
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
|
||||||
@ -235,6 +257,36 @@ private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
|||||||
$dispatcher();
|
$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
|
* @param array<string, mixed> $extraContext
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -65,6 +65,9 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
|||||||
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
|
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
|
||||||
? data_get($sections, '0.summary_payload.finding_report_buckets')
|
? data_get($sections, '0.summary_payload.finding_report_buckets')
|
||||||
: [],
|
: [],
|
||||||
|
'canonical_controls' => is_array(data_get($sections, '0.summary_payload.canonical_controls'))
|
||||||
|
? data_get($sections, '0.summary_payload.canonical_controls')
|
||||||
|
: [],
|
||||||
'report_count' => 2,
|
'report_count' => 2,
|
||||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
||||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||||
|
|||||||
@ -55,6 +55,7 @@ private function executiveSummarySection(
|
|||||||
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
|
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
|
||||||
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
|
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
|
||||||
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
||||||
|
$canonicalControls = is_array($findingsSummary['canonical_controls'] ?? null) ? $findingsSummary['canonical_controls'] : [];
|
||||||
|
|
||||||
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
||||||
$findingCount = (int) ($findingsSummary['count'] ?? 0);
|
$findingCount = (int) ($findingsSummary['count'] ?? 0);
|
||||||
@ -70,6 +71,7 @@ private function executiveSummarySection(
|
|||||||
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
||||||
sprintf('%d baseline drift findings remain open.', $driftCount),
|
sprintf('%d baseline drift findings remain open.', $driftCount),
|
||||||
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
||||||
|
$canonicalControls !== [] ? sprintf('%d canonical controls are referenced by the findings evidence.', count($canonicalControls)) : null,
|
||||||
sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)),
|
sprintf('%d risk-accepted findings are currently governed.', (int) ($riskAcceptance['valid_governed_count'] ?? 0)),
|
||||||
sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)),
|
sprintf('%d privileged Entra roles are captured in the evidence basis.', (int) ($rolesSummary['role_count'] ?? 0)),
|
||||||
]));
|
]));
|
||||||
@ -96,6 +98,8 @@ private function executiveSummarySection(
|
|||||||
'baseline_drift_count' => $driftCount,
|
'baseline_drift_count' => $driftCount,
|
||||||
'failed_operation_count' => $operationFailures,
|
'failed_operation_count' => $operationFailures,
|
||||||
'partial_operation_count' => $partialOperations,
|
'partial_operation_count' => $partialOperations,
|
||||||
|
'canonical_control_count' => count($canonicalControls),
|
||||||
|
'canonical_controls' => $canonicalControls,
|
||||||
'risk_acceptance' => $riskAcceptance,
|
'risk_acceptance' => $riskAcceptance,
|
||||||
],
|
],
|
||||||
'render_payload' => [
|
'render_payload' => [
|
||||||
@ -145,6 +149,7 @@ private function openRisksSection(?EvidenceSnapshotItem $findingsItem): array
|
|||||||
'summary_payload' => [
|
'summary_payload' => [
|
||||||
'open_count' => (int) ($summary['open_count'] ?? 0),
|
'open_count' => (int) ($summary['open_count'] ?? 0),
|
||||||
'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [],
|
'severity_counts' => is_array($summary['severity_counts'] ?? null) ? $summary['severity_counts'] : [],
|
||||||
|
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
|
||||||
],
|
],
|
||||||
'render_payload' => [
|
'render_payload' => [
|
||||||
'entries' => $entries,
|
'entries' => $entries,
|
||||||
@ -178,6 +183,7 @@ private function acceptedRisksSection(?EvidenceSnapshotItem $findingsItem): arra
|
|||||||
'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0),
|
'expired_count' => (int) ($riskAcceptance['expired_count'] ?? 0),
|
||||||
'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0),
|
'revoked_count' => (int) ($riskAcceptance['revoked_count'] ?? 0),
|
||||||
'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0),
|
'missing_exception_count' => (int) ($riskAcceptance['missing_exception_count'] ?? 0),
|
||||||
|
'canonical_controls' => $this->canonicalControlsFromEntries($entries),
|
||||||
],
|
],
|
||||||
'render_payload' => [
|
'render_payload' => [
|
||||||
'entries' => $entries,
|
'entries' => $entries,
|
||||||
@ -293,6 +299,20 @@ private function sourceFingerprint(?EvidenceSnapshotItem $item): ?string
|
|||||||
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
return is_string($fingerprint) && $fingerprint !== '' ? $fingerprint : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $entries
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function canonicalControlsFromEntries(array $entries): array
|
||||||
|
{
|
||||||
|
return collect($entries)
|
||||||
|
->map(static fn (array $entry): mixed => data_get($entry, 'canonical_control_resolution.control'))
|
||||||
|
->filter(static fn (mixed $control): bool => is_array($control) && filled($control['control_key'] ?? null))
|
||||||
|
->unique(static fn (array $control): string => (string) $control['control_key'])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, TenantReviewCompletenessState> $states
|
* @param array<int, TenantReviewCompletenessState> $states
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class ArtifactSuitability
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $baseline,
|
||||||
|
public bool $drift,
|
||||||
|
public bool $finding,
|
||||||
|
public bool $exception,
|
||||||
|
public bool $evidence,
|
||||||
|
public bool $review,
|
||||||
|
public bool $report,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
foreach (self::requiredKeys() as $key) {
|
||||||
|
if (! array_key_exists($key, $data)) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Canonical control artifact suitability is missing [%s].', $key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
baseline: (bool) $data['baseline'],
|
||||||
|
drift: (bool) $data['drift'],
|
||||||
|
finding: (bool) $data['finding'],
|
||||||
|
exception: (bool) $data['exception'],
|
||||||
|
evidence: (bool) $data['evidence'],
|
||||||
|
review: (bool) $data['review'],
|
||||||
|
report: (bool) $data['report'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool}
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'baseline' => $this->baseline,
|
||||||
|
'drift' => $this->drift,
|
||||||
|
'finding' => $this->finding,
|
||||||
|
'exception' => $this->exception,
|
||||||
|
'evidence' => $this->evidence,
|
||||||
|
'review' => $this->review,
|
||||||
|
'report' => $this->report,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function requiredKeys(): array
|
||||||
|
{
|
||||||
|
return ['baseline', 'drift', 'finding', 'exception', 'evidence', 'review', 'report'];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CanonicalControlCatalog
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<CanonicalControlDefinition>
|
||||||
|
*/
|
||||||
|
private array $definitions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<MicrosoftSubjectBinding>
|
||||||
|
*/
|
||||||
|
private array $microsoftBindings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>>|null $controls
|
||||||
|
*/
|
||||||
|
public function __construct(?array $controls = null)
|
||||||
|
{
|
||||||
|
$controls ??= config('canonical_controls.controls', []);
|
||||||
|
|
||||||
|
if (! is_array($controls)) {
|
||||||
|
throw new InvalidArgumentException('Canonical controls config must define a controls array.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->definitions = [];
|
||||||
|
$this->microsoftBindings = [];
|
||||||
|
|
||||||
|
foreach ($controls as $control) {
|
||||||
|
if (! is_array($control)) {
|
||||||
|
throw new InvalidArgumentException('Canonical control entries must be arrays.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = CanonicalControlDefinition::fromArray($control);
|
||||||
|
|
||||||
|
if ($this->find($definition->controlKey) instanceof CanonicalControlDefinition) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Duplicate canonical control key [%s].', $definition->controlKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->definitions[] = $definition;
|
||||||
|
|
||||||
|
$bindings = is_array($control['microsoft_bindings'] ?? null) ? $control['microsoft_bindings'] : [];
|
||||||
|
|
||||||
|
foreach ($bindings as $binding) {
|
||||||
|
if (! is_array($binding)) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Microsoft bindings for [%s] must be arrays.', $definition->controlKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->microsoftBindings[] = MicrosoftSubjectBinding::fromArray($definition->controlKey, $binding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usort(
|
||||||
|
$this->definitions,
|
||||||
|
static fn (CanonicalControlDefinition $left, CanonicalControlDefinition $right): int => $left->controlKey <=> $right->controlKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<CanonicalControlDefinition>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->definitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<CanonicalControlDefinition>
|
||||||
|
*/
|
||||||
|
public function active(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->definitions,
|
||||||
|
static fn (CanonicalControlDefinition $definition): bool => ! $definition->isRetired(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(string $controlKey): ?CanonicalControlDefinition
|
||||||
|
{
|
||||||
|
$controlKey = trim($controlKey);
|
||||||
|
|
||||||
|
foreach ($this->definitions as $definition) {
|
||||||
|
if ($definition->controlKey === $controlKey) {
|
||||||
|
return $definition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<MicrosoftSubjectBinding>
|
||||||
|
*/
|
||||||
|
public function microsoftBindings(): array
|
||||||
|
{
|
||||||
|
return $this->microsoftBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<MicrosoftSubjectBinding>
|
||||||
|
*/
|
||||||
|
public function microsoftBindingsForControl(string $controlKey): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(
|
||||||
|
$this->microsoftBindings,
|
||||||
|
static fn (MicrosoftSubjectBinding $binding): bool => $binding->controlKey === trim($controlKey),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listPayload(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (CanonicalControlDefinition $definition): array => $definition->toArray(),
|
||||||
|
$this->all(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class CanonicalControlDefinition
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<EvidenceArchetype> $evidenceArchetypes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $controlKey,
|
||||||
|
public string $name,
|
||||||
|
public string $domainKey,
|
||||||
|
public string $subdomainKey,
|
||||||
|
public string $controlClass,
|
||||||
|
public string $summary,
|
||||||
|
public string $operatorDescription,
|
||||||
|
public DetectabilityClass $detectabilityClass,
|
||||||
|
public EvaluationStrategy $evaluationStrategy,
|
||||||
|
public array $evidenceArchetypes,
|
||||||
|
public ArtifactSuitability $artifactSuitability,
|
||||||
|
public string $historicalStatus = 'active',
|
||||||
|
) {
|
||||||
|
foreach ([
|
||||||
|
'control key' => $this->controlKey,
|
||||||
|
'name' => $this->name,
|
||||||
|
'domain key' => $this->domainKey,
|
||||||
|
'subdomain key' => $this->subdomainKey,
|
||||||
|
'control class' => $this->controlClass,
|
||||||
|
'summary' => $this->summary,
|
||||||
|
'operator description' => $this->operatorDescription,
|
||||||
|
'historical status' => $this->historicalStatus,
|
||||||
|
] as $label => $value) {
|
||||||
|
if (trim($value) === '') {
|
||||||
|
throw new InvalidArgumentException(sprintf('Canonical control definitions require a non-empty %s.', $label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->controlKey !== mb_strtolower($this->controlKey) || preg_match('/^[a-z][a-z0-9_]*$/', $this->controlKey) !== 1) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Canonical control key [%s] must be a lowercase provider-neutral slug.', $this->controlKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($this->historicalStatus, ['active', 'retired'], true)) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Canonical control [%s] has an unsupported historical status.', $this->controlKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->evidenceArchetypes === []) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Canonical control [%s] must declare at least one evidence archetype.', $this->controlKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
controlKey: (string) ($data['control_key'] ?? ''),
|
||||||
|
name: (string) ($data['name'] ?? ''),
|
||||||
|
domainKey: (string) ($data['domain_key'] ?? ''),
|
||||||
|
subdomainKey: (string) ($data['subdomain_key'] ?? ''),
|
||||||
|
controlClass: (string) ($data['control_class'] ?? ''),
|
||||||
|
summary: (string) ($data['summary'] ?? ''),
|
||||||
|
operatorDescription: (string) ($data['operator_description'] ?? ''),
|
||||||
|
detectabilityClass: DetectabilityClass::from((string) ($data['detectability_class'] ?? '')),
|
||||||
|
evaluationStrategy: EvaluationStrategy::from((string) ($data['evaluation_strategy'] ?? '')),
|
||||||
|
evidenceArchetypes: self::evidenceArchetypes($data['evidence_archetypes'] ?? []),
|
||||||
|
artifactSuitability: ArtifactSuitability::fromArray(is_array($data['artifact_suitability'] ?? null) ? $data['artifact_suitability'] : []),
|
||||||
|
historicalStatus: (string) ($data['historical_status'] ?? 'active'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* control_key: string,
|
||||||
|
* name: string,
|
||||||
|
* domain_key: string,
|
||||||
|
* subdomain_key: string,
|
||||||
|
* control_class: string,
|
||||||
|
* summary: string,
|
||||||
|
* operator_description: string,
|
||||||
|
* detectability_class: string,
|
||||||
|
* evaluation_strategy: string,
|
||||||
|
* evidence_archetypes: list<string>,
|
||||||
|
* artifact_suitability: array{baseline: bool, drift: bool, finding: bool, exception: bool, evidence: bool, review: bool, report: bool},
|
||||||
|
* historical_status: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'control_key' => $this->controlKey,
|
||||||
|
'name' => $this->name,
|
||||||
|
'domain_key' => $this->domainKey,
|
||||||
|
'subdomain_key' => $this->subdomainKey,
|
||||||
|
'control_class' => $this->controlClass,
|
||||||
|
'summary' => $this->summary,
|
||||||
|
'operator_description' => $this->operatorDescription,
|
||||||
|
'detectability_class' => $this->detectabilityClass->value,
|
||||||
|
'evaluation_strategy' => $this->evaluationStrategy->value,
|
||||||
|
'evidence_archetypes' => array_map(
|
||||||
|
static fn (EvidenceArchetype $archetype): string => $archetype->value,
|
||||||
|
$this->evidenceArchetypes,
|
||||||
|
),
|
||||||
|
'artifact_suitability' => $this->artifactSuitability->toArray(),
|
||||||
|
'historical_status' => $this->historicalStatus,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isRetired(): bool
|
||||||
|
{
|
||||||
|
return $this->historicalStatus === 'retired';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<mixed> $values
|
||||||
|
* @return list<EvidenceArchetype>
|
||||||
|
*/
|
||||||
|
private static function evidenceArchetypes(iterable $values): array
|
||||||
|
{
|
||||||
|
return collect($values)
|
||||||
|
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->map(static fn (string $value): EvidenceArchetype => EvidenceArchetype::from(trim($value)))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
final readonly class CanonicalControlResolutionRequest
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $provider,
|
||||||
|
public string $consumerContext,
|
||||||
|
public ?string $subjectFamilyKey = null,
|
||||||
|
public ?string $workload = null,
|
||||||
|
public ?string $signalKey = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
provider: self::normalize((string) ($data['provider'] ?? '')),
|
||||||
|
consumerContext: self::normalize((string) ($data['consumer_context'] ?? '')),
|
||||||
|
subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null),
|
||||||
|
workload: self::optionalString($data['workload'] ?? null),
|
||||||
|
signalKey: self::optionalString($data['signal_key'] ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasDiscriminator(): bool
|
||||||
|
{
|
||||||
|
return $this->subjectFamilyKey !== null || $this->workload !== null || $this->signalKey !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{provider: string, subject_family_key: ?string, workload: ?string, signal_key: ?string, consumer_context: string}
|
||||||
|
*/
|
||||||
|
public function bindingContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'subject_family_key' => $this->subjectFamilyKey,
|
||||||
|
'workload' => $this->workload,
|
||||||
|
'signal_key' => $this->signalKey,
|
||||||
|
'consumer_context' => $this->consumerContext,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optionalString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = self::normalize($value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalize(string $value): string
|
||||||
|
{
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
final readonly class CanonicalControlResolutionResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $candidateControlKeys
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
public string $status,
|
||||||
|
public ?CanonicalControlDefinition $control,
|
||||||
|
public ?string $reasonCode,
|
||||||
|
public array $bindingContext,
|
||||||
|
public array $candidateControlKeys = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function resolved(CanonicalControlDefinition $definition): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
status: 'resolved',
|
||||||
|
control: $definition,
|
||||||
|
reasonCode: null,
|
||||||
|
bindingContext: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unresolved(string $reasonCode, CanonicalControlResolutionRequest $request): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
status: 'unresolved',
|
||||||
|
control: null,
|
||||||
|
reasonCode: $reasonCode,
|
||||||
|
bindingContext: $request->bindingContext(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $candidateControlKeys
|
||||||
|
*/
|
||||||
|
public static function ambiguous(array $candidateControlKeys, CanonicalControlResolutionRequest $request): self
|
||||||
|
{
|
||||||
|
sort($candidateControlKeys, SORT_STRING);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
status: 'ambiguous',
|
||||||
|
control: null,
|
||||||
|
reasonCode: 'ambiguous_binding',
|
||||||
|
bindingContext: $request->bindingContext(),
|
||||||
|
candidateControlKeys: array_values(array_unique($candidateControlKeys)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isResolved(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'resolved' && $this->control instanceof CanonicalControlDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
if ($this->isResolved()) {
|
||||||
|
return [
|
||||||
|
'status' => 'resolved',
|
||||||
|
'control' => $this->control?->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->status === 'ambiguous') {
|
||||||
|
return [
|
||||||
|
'status' => 'ambiguous',
|
||||||
|
'reason_code' => $this->reasonCode,
|
||||||
|
'candidate_control_keys' => $this->candidateControlKeys,
|
||||||
|
'binding_context' => $this->bindingContext,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'unresolved',
|
||||||
|
'reason_code' => $this->reasonCode,
|
||||||
|
'binding_context' => $this->bindingContext,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
final readonly class CanonicalControlResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const SUPPORTED_CONTEXTS = ['baseline', 'drift', 'finding', 'evidence', 'exception', 'review', 'report'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private CanonicalControlCatalog $catalog,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function resolve(CanonicalControlResolutionRequest $request): CanonicalControlResolutionResult
|
||||||
|
{
|
||||||
|
if ($request->provider !== 'microsoft') {
|
||||||
|
return CanonicalControlResolutionResult::unresolved('unsupported_provider', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($request->consumerContext, self::SUPPORTED_CONTEXTS, true)) {
|
||||||
|
return CanonicalControlResolutionResult::unresolved('unsupported_consumer_context', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->hasDiscriminator()) {
|
||||||
|
return CanonicalControlResolutionResult::unresolved('insufficient_context', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bindings = array_values(array_filter(
|
||||||
|
$this->catalog->microsoftBindings(),
|
||||||
|
static fn (MicrosoftSubjectBinding $binding): bool => $binding->matches($request),
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($bindings === []) {
|
||||||
|
return CanonicalControlResolutionResult::unresolved('missing_binding', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$primaryBindings = array_values(array_filter(
|
||||||
|
$bindings,
|
||||||
|
static fn (MicrosoftSubjectBinding $binding): bool => $binding->primary,
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($primaryBindings !== []) {
|
||||||
|
$bindings = $primaryBindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidateControlKeys = array_values(array_unique(array_map(
|
||||||
|
static fn (MicrosoftSubjectBinding $binding): string => $binding->controlKey,
|
||||||
|
$bindings,
|
||||||
|
)));
|
||||||
|
|
||||||
|
sort($candidateControlKeys, SORT_STRING);
|
||||||
|
|
||||||
|
if (count($candidateControlKeys) !== 1) {
|
||||||
|
return CanonicalControlResolutionResult::ambiguous($candidateControlKeys, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $this->catalog->find($candidateControlKeys[0]);
|
||||||
|
|
||||||
|
if (! $definition instanceof CanonicalControlDefinition) {
|
||||||
|
return CanonicalControlResolutionResult::unresolved('missing_binding', $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CanonicalControlResolutionResult::resolved($definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
enum DetectabilityClass: string
|
||||||
|
{
|
||||||
|
case DirectTechnical = 'direct_technical';
|
||||||
|
case IndirectTechnical = 'indirect_technical';
|
||||||
|
case WorkflowAttested = 'workflow_attested';
|
||||||
|
case ExternalEvidenceOnly = 'external_evidence_only';
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
enum EvaluationStrategy: string
|
||||||
|
{
|
||||||
|
case StateEvaluated = 'state_evaluated';
|
||||||
|
case SignalInferred = 'signal_inferred';
|
||||||
|
case WorkflowConfirmed = 'workflow_confirmed';
|
||||||
|
case ExternallyAttested = 'externally_attested';
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
enum EvidenceArchetype: string
|
||||||
|
{
|
||||||
|
case ConfigurationSnapshot = 'configuration_snapshot';
|
||||||
|
case ExecutionResult = 'execution_result';
|
||||||
|
case PolicyOrAssignmentSummary = 'policy_or_assignment_summary';
|
||||||
|
case OperatorAttestation = 'operator_attestation';
|
||||||
|
case ExternalArtifactReference = 'external_artifact_reference';
|
||||||
|
}
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Governance\Controls;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class MicrosoftSubjectBinding
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $signalKeys
|
||||||
|
* @param list<string> $supportedContexts
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $controlKey,
|
||||||
|
public ?string $subjectFamilyKey,
|
||||||
|
public ?string $workload,
|
||||||
|
public array $signalKeys,
|
||||||
|
public array $supportedContexts,
|
||||||
|
public bool $primary = false,
|
||||||
|
public ?string $notes = null,
|
||||||
|
) {
|
||||||
|
if (trim($this->controlKey) === '') {
|
||||||
|
throw new InvalidArgumentException('Microsoft subject bindings require a canonical control key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->subjectFamilyKey === null && $this->workload === null && $this->signalKeys === []) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one discriminator.', $this->controlKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->supportedContexts === []) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Microsoft subject binding for [%s] requires at least one supported context.', $this->controlKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public static function fromArray(string $controlKey, array $data): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
controlKey: $controlKey,
|
||||||
|
subjectFamilyKey: self::optionalString($data['subject_family_key'] ?? null),
|
||||||
|
workload: self::optionalString($data['workload'] ?? null),
|
||||||
|
signalKeys: self::stringList($data['signal_keys'] ?? []),
|
||||||
|
supportedContexts: self::stringList($data['supported_contexts'] ?? []),
|
||||||
|
primary: (bool) ($data['primary'] ?? false),
|
||||||
|
notes: self::optionalString($data['notes'] ?? null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsContext(string $consumerContext): bool
|
||||||
|
{
|
||||||
|
return in_array(trim($consumerContext), $this->supportedContexts, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function matches(CanonicalControlResolutionRequest $request): bool
|
||||||
|
{
|
||||||
|
if ($request->provider !== 'microsoft') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->supportsContext($request->consumerContext)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->subjectFamilyKey !== null && $this->subjectFamilyKey !== $request->subjectFamilyKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->workload !== null && $this->workload !== $request->workload) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->signalKey !== null && ! in_array($request->signalKey, $this->signalKeys, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* control_key: string,
|
||||||
|
* provider: string,
|
||||||
|
* subject_family_key: ?string,
|
||||||
|
* workload: ?string,
|
||||||
|
* signal_keys: list<string>,
|
||||||
|
* supported_contexts: list<string>,
|
||||||
|
* primary: bool,
|
||||||
|
* notes: ?string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'control_key' => $this->controlKey,
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'subject_family_key' => $this->subjectFamilyKey,
|
||||||
|
'workload' => $this->workload,
|
||||||
|
'signal_keys' => $this->signalKeys,
|
||||||
|
'supported_contexts' => $this->supportedContexts,
|
||||||
|
'primary' => $this->primary,
|
||||||
|
'notes' => $this->notes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function optionalString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return $trimmed === '' ? null : $trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<mixed> $values
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function stringList(iterable $values): array
|
||||||
|
{
|
||||||
|
return collect($values)
|
||||||
|
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->map(static fn (string $value): string => trim($value))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,8 @@ final class ProviderReasonCodes
|
|||||||
|
|
||||||
public const string ProviderConnectionReviewRequired = 'provider_connection_review_required';
|
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 ProviderAuthFailed = 'provider_auth_failed';
|
||||||
|
|
||||||
public const string ProviderPermissionMissing = 'provider_permission_missing';
|
public const string ProviderPermissionMissing = 'provider_permission_missing';
|
||||||
@ -77,6 +79,7 @@ public static function all(): array
|
|||||||
self::ProviderConsentFailed,
|
self::ProviderConsentFailed,
|
||||||
self::ProviderConsentRevoked,
|
self::ProviderConsentRevoked,
|
||||||
self::ProviderConnectionReviewRequired,
|
self::ProviderConnectionReviewRequired,
|
||||||
|
self::ProviderBindingUnsupported,
|
||||||
self::ProviderAuthFailed,
|
self::ProviderAuthFailed,
|
||||||
self::ProviderPermissionMissing,
|
self::ProviderPermissionMissing,
|
||||||
self::ProviderPermissionDenied,
|
self::ProviderPermissionDenied,
|
||||||
@ -139,6 +142,7 @@ public static function platformReasonFamily(string $reasonCode): PlatformReasonF
|
|||||||
self::ProviderAuthFailed => PlatformReasonFamily::Availability,
|
self::ProviderAuthFailed => PlatformReasonFamily::Availability,
|
||||||
self::ProviderConnectionTypeInvalid,
|
self::ProviderConnectionTypeInvalid,
|
||||||
self::TenantTargetMismatch,
|
self::TenantTargetMismatch,
|
||||||
|
self::ProviderBindingUnsupported,
|
||||||
self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility,
|
self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility,
|
||||||
default => PlatformReasonFamily::Prerequisite,
|
default => PlatformReasonFamily::Prerequisite,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -141,6 +141,13 @@ public function translate(string $reasonCode, string $surface = 'detail', array
|
|||||||
actionability: 'prerequisite_missing',
|
actionability: 'prerequisite_missing',
|
||||||
nextSteps: $nextSteps,
|
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(
|
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
|
||||||
reasonCode: $normalizedCode,
|
reasonCode: $normalizedCode,
|
||||||
operatorLabel: 'Provider authentication failed',
|
operatorLabel: 'Provider authentication failed',
|
||||||
@ -284,7 +291,8 @@ private function nextStepsFor(
|
|||||||
ProviderReasonCodes::TenantTargetMismatch,
|
ProviderReasonCodes::TenantTargetMismatch,
|
||||||
ProviderReasonCodes::PlatformIdentityMissing,
|
ProviderReasonCodes::PlatformIdentityMissing,
|
||||||
ProviderReasonCodes::PlatformIdentityIncomplete,
|
ProviderReasonCodes::PlatformIdentityIncomplete,
|
||||||
ProviderReasonCodes::ProviderConnectionReviewRequired => [
|
ProviderReasonCodes::ProviderConnectionReviewRequired,
|
||||||
|
ProviderReasonCodes::ProviderBindingUnsupported => [
|
||||||
NextStepOption::link(
|
NextStepOption::link(
|
||||||
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
|
||||||
destination: $connection instanceof ProviderConnection
|
destination: $connection instanceof ProviderConnection
|
||||||
|
|||||||
304
apps/platform/config/canonical_controls.php
Normal file
304
apps/platform/config/canonical_controls.php
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'controls' => [
|
||||||
|
[
|
||||||
|
'control_key' => 'strong_authentication',
|
||||||
|
'name' => 'Strong authentication',
|
||||||
|
'domain_key' => 'identity_access',
|
||||||
|
'subdomain_key' => 'authentication_assurance',
|
||||||
|
'control_class' => 'preventive',
|
||||||
|
'summary' => 'Accounts and privileged actions require strong authentication before access is granted.',
|
||||||
|
'operator_description' => 'Use this control when the governance objective is proving that access depends on multi-factor or similarly strong authentication.',
|
||||||
|
'detectability_class' => 'indirect_technical',
|
||||||
|
'evaluation_strategy' => 'signal_inferred',
|
||||||
|
'evidence_archetypes' => [
|
||||||
|
'configuration_snapshot',
|
||||||
|
'policy_or_assignment_summary',
|
||||||
|
'execution_result',
|
||||||
|
],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => true,
|
||||||
|
'drift' => true,
|
||||||
|
'finding' => true,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'conditional_access_policy',
|
||||||
|
'workload' => 'entra',
|
||||||
|
'signal_keys' => [
|
||||||
|
'conditional_access.require_mfa',
|
||||||
|
'conditional_access.authentication_strength',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Microsoft conditional access is provider-owned evidence for strong authentication, not the canonical control identity.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'permission_posture',
|
||||||
|
'workload' => 'entra',
|
||||||
|
'signal_keys' => [
|
||||||
|
'permission_posture.required_graph_permission',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => false,
|
||||||
|
'notes' => 'Permission posture can support authentication governance when missing permissions block assessment evidence.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'control_key' => 'conditional_access_enforcement',
|
||||||
|
'name' => 'Conditional access enforcement',
|
||||||
|
'domain_key' => 'identity_access',
|
||||||
|
'subdomain_key' => 'access_policy',
|
||||||
|
'control_class' => 'preventive',
|
||||||
|
'summary' => 'Access decisions are governed by explicit policy conditions and assignment boundaries.',
|
||||||
|
'operator_description' => 'Use this control when evaluating whether access is constrained by conditional policies rather than unmanaged default access.',
|
||||||
|
'detectability_class' => 'direct_technical',
|
||||||
|
'evaluation_strategy' => 'state_evaluated',
|
||||||
|
'evidence_archetypes' => [
|
||||||
|
'configuration_snapshot',
|
||||||
|
'policy_or_assignment_summary',
|
||||||
|
],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => true,
|
||||||
|
'drift' => true,
|
||||||
|
'finding' => true,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'conditional_access_policy',
|
||||||
|
'workload' => 'entra',
|
||||||
|
'signal_keys' => [
|
||||||
|
'conditional_access.policy_state',
|
||||||
|
'conditional_access.assignment_scope',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Policy state and assignments are Microsoft-owned signals for the provider-neutral access enforcement objective.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'control_key' => 'privileged_access_governance',
|
||||||
|
'name' => 'Privileged access governance',
|
||||||
|
'domain_key' => 'identity_access',
|
||||||
|
'subdomain_key' => 'privileged_access',
|
||||||
|
'control_class' => 'preventive',
|
||||||
|
'summary' => 'Privileged roles are assigned intentionally, reviewed, and limited to accountable identities.',
|
||||||
|
'operator_description' => 'Use this control when privileged role exposure, ownership, and reviewability are the core governance objective.',
|
||||||
|
'detectability_class' => 'indirect_technical',
|
||||||
|
'evaluation_strategy' => 'signal_inferred',
|
||||||
|
'evidence_archetypes' => [
|
||||||
|
'policy_or_assignment_summary',
|
||||||
|
'execution_result',
|
||||||
|
'operator_attestation',
|
||||||
|
],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => false,
|
||||||
|
'drift' => false,
|
||||||
|
'finding' => true,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'entra_admin_roles',
|
||||||
|
'workload' => 'entra',
|
||||||
|
'signal_keys' => [
|
||||||
|
'entra_admin_roles.global_admin_assignment',
|
||||||
|
'entra_admin_roles.privileged_role_assignment',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Directory role assignment data supports privileged access governance without becoming the control taxonomy.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'control_key' => 'external_sharing_boundaries',
|
||||||
|
'name' => 'External sharing boundaries',
|
||||||
|
'domain_key' => 'collaboration_boundary',
|
||||||
|
'subdomain_key' => 'external_access',
|
||||||
|
'control_class' => 'preventive',
|
||||||
|
'summary' => 'External access and sharing are constrained by explicit tenant or workload boundaries.',
|
||||||
|
'operator_description' => 'Use this control when the product needs to explain whether cross-boundary collaboration is intentionally limited.',
|
||||||
|
'detectability_class' => 'workflow_attested',
|
||||||
|
'evaluation_strategy' => 'workflow_confirmed',
|
||||||
|
'evidence_archetypes' => [
|
||||||
|
'configuration_snapshot',
|
||||||
|
'operator_attestation',
|
||||||
|
'external_artifact_reference',
|
||||||
|
],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => false,
|
||||||
|
'drift' => false,
|
||||||
|
'finding' => false,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'sharing_boundary',
|
||||||
|
'workload' => 'microsoft_365',
|
||||||
|
'signal_keys' => [
|
||||||
|
'sharing.external_boundary_attested',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Current release coverage depends on attested configuration evidence rather than direct universal evaluation.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'control_key' => 'endpoint_hardening_compliance',
|
||||||
|
'name' => 'Endpoint hardening and compliance',
|
||||||
|
'domain_key' => 'endpoint_security',
|
||||||
|
'subdomain_key' => 'device_posture',
|
||||||
|
'control_class' => 'detective',
|
||||||
|
'summary' => 'Endpoint configuration and compliance policies express the expected device hardening posture.',
|
||||||
|
'operator_description' => 'Use this control when a finding or review references device configuration, compliance, or hardening drift.',
|
||||||
|
'detectability_class' => 'direct_technical',
|
||||||
|
'evaluation_strategy' => 'state_evaluated',
|
||||||
|
'evidence_archetypes' => [
|
||||||
|
'configuration_snapshot',
|
||||||
|
'policy_or_assignment_summary',
|
||||||
|
'execution_result',
|
||||||
|
],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => true,
|
||||||
|
'drift' => true,
|
||||||
|
'finding' => true,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'deviceConfiguration',
|
||||||
|
'workload' => 'intune',
|
||||||
|
'signal_keys' => [
|
||||||
|
'intune.device_configuration_drift',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Intune device configuration drift is a provider signal for the endpoint hardening control.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'deviceCompliancePolicy',
|
||||||
|
'workload' => 'intune',
|
||||||
|
'signal_keys' => [
|
||||||
|
'intune.device_compliance_policy',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['baseline', 'drift', 'finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Device compliance policy data supports the same endpoint hardening objective.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'drift',
|
||||||
|
'workload' => 'intune',
|
||||||
|
'signal_keys' => [
|
||||||
|
'finding.drift',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Legacy drift findings without a policy-family discriminator resolve to the broad endpoint hardening objective.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'control_key' => 'audit_log_retention',
|
||||||
|
'name' => 'Audit log retention',
|
||||||
|
'domain_key' => 'auditability',
|
||||||
|
'subdomain_key' => 'retention',
|
||||||
|
'control_class' => 'detective',
|
||||||
|
'summary' => 'Administrative and security-relevant activity remains available for investigation for the required retention period.',
|
||||||
|
'operator_description' => 'Use this control when evidence depends on retained logs or exported audit artifacts rather than live configuration alone.',
|
||||||
|
'detectability_class' => 'external_evidence_only',
|
||||||
|
'evaluation_strategy' => 'externally_attested',
|
||||||
|
'evidence_archetypes' => [
|
||||||
|
'external_artifact_reference',
|
||||||
|
'operator_attestation',
|
||||||
|
],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => false,
|
||||||
|
'drift' => false,
|
||||||
|
'finding' => false,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'audit_log_retention',
|
||||||
|
'workload' => 'microsoft_365',
|
||||||
|
'signal_keys' => [
|
||||||
|
'audit.retention_attested',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Current evidence is external or attested until a later slice adds direct provider evaluation.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'control_key' => 'delegated_admin_boundaries',
|
||||||
|
'name' => 'Delegated admin boundaries',
|
||||||
|
'domain_key' => 'identity_access',
|
||||||
|
'subdomain_key' => 'delegated_administration',
|
||||||
|
'control_class' => 'preventive',
|
||||||
|
'summary' => 'Delegated administration is constrained by explicit role, tenant, and scope boundaries.',
|
||||||
|
'operator_description' => 'Use this control when evaluating whether delegated administrative access is bounded and reviewable.',
|
||||||
|
'detectability_class' => 'workflow_attested',
|
||||||
|
'evaluation_strategy' => 'workflow_confirmed',
|
||||||
|
'evidence_archetypes' => [
|
||||||
|
'policy_or_assignment_summary',
|
||||||
|
'operator_attestation',
|
||||||
|
],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => false,
|
||||||
|
'drift' => false,
|
||||||
|
'finding' => true,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
[
|
||||||
|
'subject_family_key' => 'delegated_admin_relationship',
|
||||||
|
'workload' => 'microsoft_365',
|
||||||
|
'signal_keys' => [
|
||||||
|
'delegated_admin.relationship_boundary',
|
||||||
|
],
|
||||||
|
'supported_contexts' => ['finding', 'evidence', 'review', 'report'],
|
||||||
|
'primary' => true,
|
||||||
|
'notes' => 'Delegated admin relationship metadata remains provider-owned and secondary to the platform control.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
115
apps/platform/config/provider_boundaries.php
Normal file
115
apps/platform/config/provider_boundaries.php
Normal 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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
|
use App\Services\Evidence\Sources\FindingsSummarySource;
|
||||||
|
|
||||||
|
it('adds shared canonical control references to findings-derived evidence summaries', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Finding::factory()->permissionPosture()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
Finding::factory()->entraAdminRoles()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$item = app(FindingsSummarySource::class)->collect($tenant);
|
||||||
|
$summary = $item['summary_payload'];
|
||||||
|
|
||||||
|
expect($summary['canonical_controls'])->toHaveCount(3)
|
||||||
|
->and(collect($summary['canonical_controls'])->pluck('control_key')->all())->toEqualCanonicalizing([
|
||||||
|
'endpoint_hardening_compliance',
|
||||||
|
'privileged_access_governance',
|
||||||
|
'strong_authentication',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($summary['entries'] as $entry) {
|
||||||
|
expect($entry['canonical_control_resolution']['status'])->toBe('resolved')
|
||||||
|
->and($entry['canonical_control_resolution']['control'])->toHaveKey('control_key')
|
||||||
|
->and($entry)->not->toHaveKey('control_label');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant);
|
||||||
|
|
||||||
|
expect($payload['summary']['canonical_controls'])->toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps missing bindings explicit instead of inventing evidence fallback labels', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
Finding::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'finding_type' => 'unknown_provider_signal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'];
|
||||||
|
$entry = $summary['entries'][0];
|
||||||
|
|
||||||
|
expect($entry['canonical_control_resolution'])->toMatchArray([
|
||||||
|
'status' => 'unresolved',
|
||||||
|
'reason_code' => 'missing_binding',
|
||||||
|
])->and($entry['canonical_control_resolution'])->not->toHaveKey('control')
|
||||||
|
->and($entry)->not->toHaveKey('control_label');
|
||||||
|
});
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlCatalog;
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlResolver;
|
||||||
|
|
||||||
|
it('lists seeded canonical controls in the logical contract shape', function (): void {
|
||||||
|
$payload = [
|
||||||
|
'controls' => app(CanonicalControlCatalog::class)->listPayload(),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($payload['controls'])->not->toBeEmpty();
|
||||||
|
|
||||||
|
foreach ($payload['controls'] as $control) {
|
||||||
|
expect($control)->toHaveKeys([
|
||||||
|
'control_key',
|
||||||
|
'name',
|
||||||
|
'domain_key',
|
||||||
|
'subdomain_key',
|
||||||
|
'control_class',
|
||||||
|
'summary',
|
||||||
|
'operator_description',
|
||||||
|
'detectability_class',
|
||||||
|
'evaluation_strategy',
|
||||||
|
'evidence_archetypes',
|
||||||
|
'artifact_suitability',
|
||||||
|
'historical_status',
|
||||||
|
])->and($control['artifact_suitability'])->toHaveKeys([
|
||||||
|
'baseline',
|
||||||
|
'drift',
|
||||||
|
'finding',
|
||||||
|
'exception',
|
||||||
|
'evidence',
|
||||||
|
'review',
|
||||||
|
'report',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns resolved, unresolved, and ambiguous resolution shapes without guessing', function (): void {
|
||||||
|
$resolver = app(CanonicalControlResolver::class);
|
||||||
|
|
||||||
|
$resolved = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'review',
|
||||||
|
subjectFamilyKey: 'entra_admin_roles',
|
||||||
|
workload: 'entra',
|
||||||
|
signalKey: 'entra_admin_roles.global_admin_assignment',
|
||||||
|
))->toArray();
|
||||||
|
$unresolved = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'review',
|
||||||
|
subjectFamilyKey: 'not_bound',
|
||||||
|
))->toArray();
|
||||||
|
$ambiguous = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'review',
|
||||||
|
subjectFamilyKey: 'conditional_access_policy',
|
||||||
|
workload: 'entra',
|
||||||
|
))->toArray();
|
||||||
|
|
||||||
|
expect($resolved)->toHaveKeys(['status', 'control'])
|
||||||
|
->and($resolved['status'])->toBe('resolved')
|
||||||
|
->and($resolved['control']['control_key'])->toBe('privileged_access_governance')
|
||||||
|
->and($unresolved)->toHaveKeys(['status', 'reason_code', 'binding_context'])
|
||||||
|
->and($unresolved['reason_code'])->toBe('missing_binding')
|
||||||
|
->and($ambiguous)->toHaveKeys(['status', 'reason_code', 'candidate_control_keys', 'binding_context'])
|
||||||
|
->and($ambiguous['status'])->toBe('ambiguous')
|
||||||
|
->and($ambiguous['candidate_control_keys'])->toEqualCanonicalizing([
|
||||||
|
'conditional_access_enforcement',
|
||||||
|
'strong_authentication',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -15,7 +15,8 @@
|
|||||||
->and(is_executable($scriptPath))->toBeTrue()
|
->and(is_executable($scriptPath))->toBeTrue()
|
||||||
->and($scriptContents)->toContain('APP_DIR')
|
->and($scriptContents)->toContain('APP_DIR')
|
||||||
->toContain('apps/platform')
|
->toContain('apps/platform')
|
||||||
->toContain('exec ./vendor/bin/sail "$@"');
|
->toContain('exec ./vendor/bin/sail "$@"')
|
||||||
|
->not->toContain('COMPOSE_PROJECT_NAME');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the repo root compose file pointed at the relocated app', function (): void {
|
it('keeps the repo root compose file pointed at the relocated app', function (): void {
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
it('passes shared canonical control references through tenant review composition', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$snapshot = seedTenantReviewEvidence($tenant, findingCount: 0, driftCount: 1);
|
||||||
|
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||||
|
$openRisks = $review->sections->firstWhere('section_key', 'open_risks');
|
||||||
|
$executiveSummary = $review->sections->firstWhere('section_key', 'executive_summary');
|
||||||
|
|
||||||
|
expect($review->canonicalControlReferences())->toHaveCount(1)
|
||||||
|
->and($review->canonicalControlReferences()[0]['control_key'])->toBe('endpoint_hardening_compliance')
|
||||||
|
->and($executiveSummary->summary_payload['canonical_control_count'])->toBe(1)
|
||||||
|
->and($executiveSummary->summary_payload['canonical_controls'][0]['control_key'])->toBe('endpoint_hardening_compliance')
|
||||||
|
->and($openRisks->summary_payload['canonical_controls'])->toBe([]);
|
||||||
|
});
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlCatalog;
|
||||||
|
use App\Support\Governance\Controls\DetectabilityClass;
|
||||||
|
use App\Support\Governance\Controls\EvaluationStrategy;
|
||||||
|
|
||||||
|
it('loads stable provider-neutral seed definitions with complete metadata', function (): void {
|
||||||
|
$catalog = app(CanonicalControlCatalog::class);
|
||||||
|
|
||||||
|
expect($catalog->all())->toHaveCount(7);
|
||||||
|
|
||||||
|
foreach ($catalog->all() as $definition) {
|
||||||
|
expect($definition->controlKey)->toMatch('/^[a-z][a-z0-9_]*$/')
|
||||||
|
->and($definition->name)->not->toBeEmpty()
|
||||||
|
->and($definition->domainKey)->not->toContain('microsoft')
|
||||||
|
->and($definition->domainKey)->not->toContain('intune')
|
||||||
|
->and($definition->subdomainKey)->not->toBeEmpty()
|
||||||
|
->and($definition->controlClass)->not->toBeEmpty()
|
||||||
|
->and($definition->summary)->not->toBeEmpty()
|
||||||
|
->and($definition->operatorDescription)->not->toBeEmpty()
|
||||||
|
->and($definition->detectabilityClass)->toBeInstanceOf(DetectabilityClass::class)
|
||||||
|
->and($definition->evaluationStrategy)->toBeInstanceOf(EvaluationStrategy::class)
|
||||||
|
->and($definition->evidenceArchetypes)->not->toBeEmpty()
|
||||||
|
->and(array_keys($definition->artifactSuitability->toArray()))->toBe([
|
||||||
|
'baseline',
|
||||||
|
'drift',
|
||||||
|
'finding',
|
||||||
|
'exception',
|
||||||
|
'evidence',
|
||||||
|
'review',
|
||||||
|
'report',
|
||||||
|
])
|
||||||
|
->and($definition->historicalStatus)->toBeIn(['active', 'retired']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the first-slice high-value control families', function (): void {
|
||||||
|
$keys = array_map(
|
||||||
|
static fn ($definition): string => $definition->controlKey,
|
||||||
|
app(CanonicalControlCatalog::class)->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($keys)->toEqualCanonicalizing([
|
||||||
|
'audit_log_retention',
|
||||||
|
'conditional_access_enforcement',
|
||||||
|
'delegated_admin_boundaries',
|
||||||
|
'endpoint_hardening_compliance',
|
||||||
|
'external_sharing_boundaries',
|
||||||
|
'privileged_access_governance',
|
||||||
|
'strong_authentication',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Microsoft bindings secondary to the definition payload', function (): void {
|
||||||
|
$catalog = app(CanonicalControlCatalog::class);
|
||||||
|
$definition = $catalog->find('endpoint_hardening_compliance');
|
||||||
|
|
||||||
|
expect($definition?->toArray())->not->toHaveKey('microsoft_bindings')
|
||||||
|
->and($catalog->microsoftBindingsForControl('endpoint_hardening_compliance'))->not->toBeEmpty()
|
||||||
|
->and($catalog->microsoftBindingsForControl('endpoint_hardening_compliance')[0]->toArray()['provider'])->toBe('microsoft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves honest detectability, evaluation, and suitability distinctions', function (): void {
|
||||||
|
$catalog = app(CanonicalControlCatalog::class);
|
||||||
|
|
||||||
|
expect($catalog->find('endpoint_hardening_compliance')?->detectabilityClass)->toBe(DetectabilityClass::DirectTechnical)
|
||||||
|
->and($catalog->find('endpoint_hardening_compliance')?->evaluationStrategy)->toBe(EvaluationStrategy::StateEvaluated)
|
||||||
|
->and($catalog->find('audit_log_retention')?->detectabilityClass)->toBe(DetectabilityClass::ExternalEvidenceOnly)
|
||||||
|
->and($catalog->find('audit_log_retention')?->evaluationStrategy)->toBe(EvaluationStrategy::ExternallyAttested)
|
||||||
|
->and($catalog->find('audit_log_retention')?->artifactSuitability->baseline)->toBeFalse()
|
||||||
|
->and($catalog->find('audit_log_retention')?->artifactSuitability->review)->toBeTrue();
|
||||||
|
});
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlCatalog;
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlResolutionRequest;
|
||||||
|
use App\Support\Governance\Controls\CanonicalControlResolver;
|
||||||
|
|
||||||
|
it('resolves multiple Microsoft subject families to one stable canonical control identity', function (): void {
|
||||||
|
$resolver = app(CanonicalControlResolver::class);
|
||||||
|
|
||||||
|
$configurationResult = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'deviceConfiguration',
|
||||||
|
workload: 'intune',
|
||||||
|
signalKey: 'intune.device_configuration_drift',
|
||||||
|
));
|
||||||
|
$complianceResult = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'review',
|
||||||
|
subjectFamilyKey: 'deviceCompliancePolicy',
|
||||||
|
workload: 'intune',
|
||||||
|
signalKey: 'intune.device_compliance_policy',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect($configurationResult->toArray()['control']['control_key'])->toBe('endpoint_hardening_compliance')
|
||||||
|
->and($complianceResult->toArray()['control']['control_key'])->toBe('endpoint_hardening_compliance')
|
||||||
|
->and($configurationResult->toArray()['control']['name'])->toBe($complianceResult->toArray()['control']['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses supplied signal context instead of letting workload labels become primary identity', function (): void {
|
||||||
|
$resolver = app(CanonicalControlResolver::class);
|
||||||
|
|
||||||
|
$strongAuthentication = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'conditional_access_policy',
|
||||||
|
workload: 'entra',
|
||||||
|
signalKey: 'conditional_access.require_mfa',
|
||||||
|
));
|
||||||
|
$accessEnforcement = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'conditional_access_policy',
|
||||||
|
workload: 'entra',
|
||||||
|
signalKey: 'conditional_access.policy_state',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect($strongAuthentication->toArray()['control']['control_key'])->toBe('strong_authentication')
|
||||||
|
->and($accessEnforcement->toArray()['control']['control_key'])->toBe('conditional_access_enforcement');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns explicit unresolved reason codes instead of fallback labels', function (): void {
|
||||||
|
$resolver = app(CanonicalControlResolver::class);
|
||||||
|
|
||||||
|
expect($resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'unknown',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'deviceConfiguration',
|
||||||
|
))->toArray())->toMatchArray([
|
||||||
|
'status' => 'unresolved',
|
||||||
|
'reason_code' => 'unsupported_provider',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
))->toArray())->toMatchArray([
|
||||||
|
'status' => 'unresolved',
|
||||||
|
'reason_code' => 'insufficient_context',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'not_bound',
|
||||||
|
))->toArray())->toMatchArray([
|
||||||
|
'status' => 'unresolved',
|
||||||
|
'reason_code' => 'missing_binding',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails deterministically when a binding context is ambiguous', function (): void {
|
||||||
|
$resolver = new CanonicalControlResolver(new CanonicalControlCatalog([
|
||||||
|
spec236ControlDefinition('first_control', [
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
spec236Binding('shared_subject', primary: false),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
spec236ControlDefinition('second_control', [
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
spec236Binding('shared_subject', primary: false),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$result = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'evidence',
|
||||||
|
subjectFamilyKey: 'shared_subject',
|
||||||
|
workload: 'entra',
|
||||||
|
signalKey: 'shared.signal',
|
||||||
|
))->toArray();
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('ambiguous')
|
||||||
|
->and($result['reason_code'])->toBe('ambiguous_binding')
|
||||||
|
->and($result['candidate_control_keys'])->toBe(['first_control', 'second_control']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps retired controls resolvable for historical references', function (): void {
|
||||||
|
$resolver = new CanonicalControlResolver(new CanonicalControlCatalog([
|
||||||
|
spec236ControlDefinition('retired_control', [
|
||||||
|
'historical_status' => 'retired',
|
||||||
|
'microsoft_bindings' => [
|
||||||
|
spec236Binding('retired_subject'),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$result = $resolver->resolve(new CanonicalControlResolutionRequest(
|
||||||
|
provider: 'microsoft',
|
||||||
|
consumerContext: 'review',
|
||||||
|
subjectFamilyKey: 'retired_subject',
|
||||||
|
workload: 'entra',
|
||||||
|
signalKey: 'shared.signal',
|
||||||
|
))->toArray();
|
||||||
|
|
||||||
|
expect($result['status'])->toBe('resolved')
|
||||||
|
->and($result['control']['control_key'])->toBe('retired_control')
|
||||||
|
->and($result['control']['historical_status'])->toBe('retired');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $overrides
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function spec236ControlDefinition(string $controlKey, array $overrides = []): array
|
||||||
|
{
|
||||||
|
return array_replace_recursive([
|
||||||
|
'control_key' => $controlKey,
|
||||||
|
'name' => str_replace('_', ' ', ucfirst($controlKey)),
|
||||||
|
'domain_key' => 'identity_access',
|
||||||
|
'subdomain_key' => 'test_subjects',
|
||||||
|
'control_class' => 'preventive',
|
||||||
|
'summary' => 'Test summary.',
|
||||||
|
'operator_description' => 'Test operator description.',
|
||||||
|
'detectability_class' => 'direct_technical',
|
||||||
|
'evaluation_strategy' => 'state_evaluated',
|
||||||
|
'evidence_archetypes' => ['configuration_snapshot'],
|
||||||
|
'artifact_suitability' => [
|
||||||
|
'baseline' => true,
|
||||||
|
'drift' => true,
|
||||||
|
'finding' => true,
|
||||||
|
'exception' => true,
|
||||||
|
'evidence' => true,
|
||||||
|
'review' => true,
|
||||||
|
'report' => true,
|
||||||
|
],
|
||||||
|
'historical_status' => 'active',
|
||||||
|
'microsoft_bindings' => [],
|
||||||
|
], $overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function spec236Binding(string $subjectFamilyKey, bool $primary = true): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'subject_family_key' => $subjectFamilyKey,
|
||||||
|
'workload' => 'entra',
|
||||||
|
'signal_keys' => ['shared.signal'],
|
||||||
|
'supported_contexts' => ['evidence', 'review'],
|
||||||
|
'primary' => $primary,
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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_id'] ?? null)->not->toBe('dedicated-fallback-client-id')
|
||||||
->and($graph->lastOptions['client_request_id'] ?? null)->toBeString()->not->toBeEmpty();
|
->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();
|
||||||
|
});
|
||||||
|
|||||||
@ -72,6 +72,27 @@
|
|||||||
->and($resolution->credentialSource)->toBe('dedicated_manual');
|
->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 {
|
it('blocks dedicated connections when dedicated credentials are missing', function (): void {
|
||||||
$connection = ProviderConnection::factory()->dedicated()->create([
|
$connection = ProviderConnection::factory()->dedicated()->create([
|
||||||
'entra_tenant_id' => 'dedicated-target-tenant-id',
|
'entra_tenant_id' => 'dedicated-target-tenant-id',
|
||||||
|
|||||||
@ -57,6 +57,8 @@
|
|||||||
'entra_tenant_id' => 'entra-tenant-id',
|
'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 {
|
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->status)->toBe('scope_busy');
|
||||||
expect($result->run->getKey())->toBe($blocking->getKey());
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@ -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.
|
Goal: Turn the new audit constitution into enforceable backend and workflow guardrails before further governance surface area lands, while preventing the Governance-of-Record platform core from drifting into provider-specific or operation-type dual semantics.
|
||||||
|
|
||||||
**Active specs**: 144
|
**Active specs**: 144
|
||||||
**Specced follow-through (draft)**: 149 (queued execution reauthorization), 150 (tenant-owned query canon), 151 (findings workflow backstop), 152 (Livewire context locking), 214 (governance outcome compression), 216 (provider dispatch gate). 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.
|
**Operator truth initiative** (sequenced): Operator Outcome Taxonomy (Spec 156) → Reason Code Translation (Spec 157) → Artifact Truth Semantics (Spec 158) → Governance Operator Outcome Compression (Spec 214, draft). Humanized Diagnostic Summaries for Governance Operations is now Spec 220 (draft) as the run-detail adoption slice, while Provider Dispatch Gate Unification is now Spec 216 (draft) as the adjacent hardening lane.
|
||||||
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
**Source**: architecture audit 2026-03-15, audit constitution, semantic clarity audit 2026-03-21, product spec-candidates
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **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-24 (added Platform Hardening — OperationRun UX Consistency cluster with OperationRun Start UX Contract, Generic Active Run Surface, OperationRun Notification Lifecycle, and OperationRun Startsurface Migration; promoted Provider Boundary Hardening to Spec 237, clarified the remaining near-term sequencing after Canonical Control Catalog Foundation and Provider Boundary Hardening, and retained `Customer Review Workspace v1` as the customer-facing review consumption candidate that sharpens the R2 read-only/customer review lane)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -52,6 +52,7 @@ ## Promoted to Spec
|
|||||||
- Finding Outcome Taxonomy & Verification Semantics → Spec 231 (`finding-outcome-taxonomy`)
|
- Finding Outcome Taxonomy & Verification Semantics → Spec 231 (`finding-outcome-taxonomy`)
|
||||||
- Operation Run Link Contract Enforcement → Spec 232 (`operation-run-link-contract`)
|
- Operation Run Link Contract Enforcement → Spec 232 (`operation-run-link-contract`)
|
||||||
- Operation Run Active-State Visibility & Stale Escalation → Spec 233 (`stale-run-visibility`)
|
- 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`)
|
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||||
@ -69,33 +70,137 @@ ## Qualified
|
|||||||
>
|
>
|
||||||
> Recommended next sequence:
|
> Recommended next sequence:
|
||||||
>
|
>
|
||||||
> 1. **Canonical Control Catalog Foundation**
|
> 1. **Provider Identity & Target Scope Neutrality**
|
||||||
> 2. **Provider Boundary Hardening**
|
> 2. **Canonical Operation Type Source of Truth**
|
||||||
> 3. **Provider Identity & Target Scope Neutrality**
|
> 3. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||||
> 4. **Canonical Operation Type Source of Truth**
|
> 4. **Customer Review Workspace v1**
|
||||||
> 5. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
|
||||||
> 6. **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.
|
||||||
|
|
||||||
|
|
||||||
|
> 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 / 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
|
### Provider Identity & Target Scope Neutrality
|
||||||
- **Type**: hardening / identity boundary
|
- **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
|
- **Source**: provider portability audit 2026-04-23 — `ProviderConnection` is generic by name but still carries Entra-shaped identity and target-scope semantics
|
||||||
@ -157,13 +262,12 @@ ### Provider Surface Vocabulary & Descriptor Cleanup
|
|||||||
- **Priority**: medium
|
- **Priority**: medium
|
||||||
|
|
||||||
> Recommended sequence for this cluster:
|
> Recommended sequence for this cluster:
|
||||||
> 1. **Provider Boundary Hardening**
|
> 1. **Provider Identity & Target Scope Neutrality**
|
||||||
> 2. **Provider Identity & Target Scope Neutrality**
|
> 2. **Governance Subject Taxonomy Decoupling**
|
||||||
> 3. **Governance Subject Taxonomy Decoupling**
|
> 3. **Compare Strategy Boundary Hardening**
|
||||||
> 4. **Compare Strategy Boundary Hardening**
|
> 4. **Provider Surface Vocabulary & Descriptor Cleanup**
|
||||||
> 5. **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
|
### Workspace Access Context and Navigation Cost Hardening
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
|
|||||||
@ -4,7 +4,7 @@ # Product Standards
|
|||||||
> Specs reference these standards; they do not redefine them.
|
> Specs reference these standards; they do not redefine them.
|
||||||
> Guard tests enforce critical constraints automatically.
|
> 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| Action Surface Contract | `docs/ui/action-surface-contract.md` | Original action surface reference (now governed by this standard) |
|
||||||
|
|||||||
@ -11,6 +11,4 @@ APP_DIR="${SCRIPT_DIR}/../apps/platform"
|
|||||||
|
|
||||||
cd "${APP_DIR}"
|
cd "${APP_DIR}"
|
||||||
|
|
||||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-tenantatlas}"
|
|
||||||
|
|
||||||
exec ./vendor/bin/sail "$@"
|
exec ./vendor/bin/sail "$@"
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Canonical Control Catalog Foundation
|
||||||
|
|
||||||
|
**Purpose**: Capture specification completeness and quality at planning handoff while keeping post-plan status aligned with the current artifact set
|
||||||
|
**Created**: 2026-04-24
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation algorithms, code diffs, or migration steps
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Repo-specific constitutional and provider-boundary references remain intentional and bounded
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation algorithms or file-by-file execution steps leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This checklist records readiness at planning handoff; `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/`, and `tasks.md` are the implementation-facing artifacts for this feature.
|
||||||
|
- The first slice remains product-seeded, persistence-neutral, and bounded to shared control resolution plus downstream evidence and tenant review adoption.
|
||||||
|
- No clarification markers remain, and the current scope is aligned across spec, plan, tasks, and supporting artifacts for implementation.
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Canonical Control Catalog Logical Contract
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Logical contract for the first canonical control catalog slice.
|
||||||
|
This describes shared internal request and response shapes for catalog lookup
|
||||||
|
and control resolution. It is not a commitment to expose public HTTP routes.
|
||||||
|
paths:
|
||||||
|
/logical/canonical-controls/catalog:
|
||||||
|
get:
|
||||||
|
summary: List the seeded canonical control definitions
|
||||||
|
operationId: listCanonicalControls
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Seeded canonical controls
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
controls:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/CanonicalControlDefinition'
|
||||||
|
required:
|
||||||
|
- controls
|
||||||
|
/logical/canonical-controls/resolve:
|
||||||
|
post:
|
||||||
|
summary: Resolve canonical control metadata for a governed subject or signal context
|
||||||
|
operationId: resolveCanonicalControl
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ControlResolutionRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Control resolution outcome
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ControlResolutionResponse'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
CanonicalControlDefinition:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
control_key:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
domain_key:
|
||||||
|
type: string
|
||||||
|
subdomain_key:
|
||||||
|
type: string
|
||||||
|
control_class:
|
||||||
|
type: string
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
operator_description:
|
||||||
|
type: string
|
||||||
|
detectability_class:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- direct_technical
|
||||||
|
- indirect_technical
|
||||||
|
- workflow_attested
|
||||||
|
- external_evidence_only
|
||||||
|
evaluation_strategy:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- state_evaluated
|
||||||
|
- signal_inferred
|
||||||
|
- workflow_confirmed
|
||||||
|
- externally_attested
|
||||||
|
evidence_archetypes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/EvidenceArchetype'
|
||||||
|
artifact_suitability:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
baseline:
|
||||||
|
type: boolean
|
||||||
|
drift:
|
||||||
|
type: boolean
|
||||||
|
finding:
|
||||||
|
type: boolean
|
||||||
|
exception:
|
||||||
|
type: boolean
|
||||||
|
evidence:
|
||||||
|
type: boolean
|
||||||
|
review:
|
||||||
|
type: boolean
|
||||||
|
report:
|
||||||
|
type: boolean
|
||||||
|
required:
|
||||||
|
- baseline
|
||||||
|
- drift
|
||||||
|
- finding
|
||||||
|
- exception
|
||||||
|
- evidence
|
||||||
|
- review
|
||||||
|
- report
|
||||||
|
historical_status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- active
|
||||||
|
- retired
|
||||||
|
required:
|
||||||
|
- control_key
|
||||||
|
- name
|
||||||
|
- domain_key
|
||||||
|
- subdomain_key
|
||||||
|
- control_class
|
||||||
|
- summary
|
||||||
|
- operator_description
|
||||||
|
- detectability_class
|
||||||
|
- evaluation_strategy
|
||||||
|
- evidence_archetypes
|
||||||
|
- artifact_suitability
|
||||||
|
- historical_status
|
||||||
|
ControlResolutionRequest:
|
||||||
|
type: object
|
||||||
|
description: All supplied discriminator fields combine conjunctively. The resolver narrows bindings by provider, consumer context, and every provided subject-family, workload, or signal value.
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- microsoft
|
||||||
|
subject_family_key:
|
||||||
|
type: string
|
||||||
|
workload:
|
||||||
|
type: string
|
||||||
|
signal_key:
|
||||||
|
type: string
|
||||||
|
consumer_context:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- baseline
|
||||||
|
- drift
|
||||||
|
- finding
|
||||||
|
- evidence
|
||||||
|
- exception
|
||||||
|
- review
|
||||||
|
- report
|
||||||
|
anyOf:
|
||||||
|
- required:
|
||||||
|
- subject_family_key
|
||||||
|
- required:
|
||||||
|
- workload
|
||||||
|
- required:
|
||||||
|
- signal_key
|
||||||
|
required:
|
||||||
|
- provider
|
||||||
|
- consumer_context
|
||||||
|
BindingContext:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- microsoft
|
||||||
|
subject_family_key:
|
||||||
|
type: string
|
||||||
|
workload:
|
||||||
|
type: string
|
||||||
|
signal_key:
|
||||||
|
type: string
|
||||||
|
consumer_context:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- baseline
|
||||||
|
- drift
|
||||||
|
- finding
|
||||||
|
- evidence
|
||||||
|
- exception
|
||||||
|
- review
|
||||||
|
- report
|
||||||
|
EvidenceArchetype:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- configuration_snapshot
|
||||||
|
- execution_result
|
||||||
|
- policy_or_assignment_summary
|
||||||
|
- operator_attestation
|
||||||
|
- external_artifact_reference
|
||||||
|
UnresolvedControlResolutionReasonCode:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- missing_binding
|
||||||
|
- unsupported_provider
|
||||||
|
- unsupported_consumer_context
|
||||||
|
- insufficient_context
|
||||||
|
AmbiguousControlResolutionReasonCode:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- ambiguous_binding
|
||||||
|
ResolvedControlResolutionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- resolved
|
||||||
|
control:
|
||||||
|
$ref: '#/components/schemas/CanonicalControlDefinition'
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- control
|
||||||
|
UnresolvedControlResolutionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- unresolved
|
||||||
|
reason_code:
|
||||||
|
$ref: '#/components/schemas/UnresolvedControlResolutionReasonCode'
|
||||||
|
binding_context:
|
||||||
|
$ref: '#/components/schemas/BindingContext'
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- reason_code
|
||||||
|
- binding_context
|
||||||
|
AmbiguousControlResolutionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- ambiguous
|
||||||
|
reason_code:
|
||||||
|
$ref: '#/components/schemas/AmbiguousControlResolutionReasonCode'
|
||||||
|
candidate_control_keys:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
binding_context:
|
||||||
|
$ref: '#/components/schemas/BindingContext'
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- reason_code
|
||||||
|
- candidate_control_keys
|
||||||
|
- binding_context
|
||||||
|
ControlResolutionResponse:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/ResolvedControlResolutionResponse'
|
||||||
|
- $ref: '#/components/schemas/UnresolvedControlResolutionResponse'
|
||||||
|
- $ref: '#/components/schemas/AmbiguousControlResolutionResponse'
|
||||||
133
specs/236-canonical-control-catalog-foundation/data-model.md
Normal file
133
specs/236-canonical-control-catalog-foundation/data-model.md
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
# Data Model: Canonical Control Catalog Foundation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The first slice introduces a product-seeded control catalog and a shared resolution contract. The catalog itself is not operator-managed persistence in v1; it is a bounded canonical registry consumed by existing governance domains.
|
||||||
|
|
||||||
|
## Entity: CanonicalControlDefinition
|
||||||
|
|
||||||
|
- **Purpose**: Represents one stable governance control objective independent of framework clauses, provider identifiers, or individual workload payloads.
|
||||||
|
- **Identity**:
|
||||||
|
- `control_key` — stable canonical slug, unique across the catalog
|
||||||
|
- **Core fields**:
|
||||||
|
- `name`
|
||||||
|
- `domain_key`
|
||||||
|
- `subdomain_key`
|
||||||
|
- `control_class`
|
||||||
|
- `summary`
|
||||||
|
- `operator_description`
|
||||||
|
- **Semantics fields**:
|
||||||
|
- `detectability_class`
|
||||||
|
- `evaluation_strategy`
|
||||||
|
- `evidence_archetypes[]`
|
||||||
|
- `artifact_suitability`
|
||||||
|
- `historical_status` — `active` or `retired`
|
||||||
|
- **Validation rules**:
|
||||||
|
- `control_key` must be stable, lowercase, and provider-neutral.
|
||||||
|
- `domain_key` and `subdomain_key` must point to canonical catalog taxonomy, not framework or provider namespaces.
|
||||||
|
- Each control must declare at least one evidence archetype.
|
||||||
|
- Each control must declare explicit suitability flags for baseline, drift, finding, exception, evidence, review, and report usage.
|
||||||
|
|
||||||
|
## Entity: MicrosoftSubjectBinding
|
||||||
|
|
||||||
|
- **Purpose**: Connects provider-owned Microsoft subjects, workloads, or signals to one canonical control without redefining the control.
|
||||||
|
- **Fields**:
|
||||||
|
- `control_key`
|
||||||
|
- `provider` — always `microsoft` in the first slice
|
||||||
|
- `subject_family_key`
|
||||||
|
- `workload`
|
||||||
|
- `signal_keys[]`
|
||||||
|
- `supported_contexts[]` — for example baseline, finding, evidence, exception, review, report
|
||||||
|
- `primary` — whether this binding is the default control for the declared context
|
||||||
|
- `notes`
|
||||||
|
- **Validation rules**:
|
||||||
|
- Every binding must reference an existing `control_key`.
|
||||||
|
- Provider-specific descriptors must not overwrite control-core terminology.
|
||||||
|
- More than one binding may point to the same control.
|
||||||
|
- Multiple controls may only claim the same binding context when the ambiguity is intentionally declared and handled.
|
||||||
|
|
||||||
|
## Entity: CanonicalControlResolutionResult
|
||||||
|
|
||||||
|
- **Purpose**: Shared response contract for downstream consumers.
|
||||||
|
- **Resolver matching rule**: provider, consumer context, and every supplied subject-family, workload, or signal discriminator combine conjunctively to narrow candidate bindings.
|
||||||
|
- **States**:
|
||||||
|
- `resolved`
|
||||||
|
- `unresolved`
|
||||||
|
- `ambiguous`
|
||||||
|
- **Fields when resolved**:
|
||||||
|
- `status` — always `resolved`
|
||||||
|
- `control` — full `CanonicalControlDefinition` payload containing:
|
||||||
|
- `control_key`
|
||||||
|
- `name`
|
||||||
|
- `domain_key`
|
||||||
|
- `subdomain_key`
|
||||||
|
- `control_class`
|
||||||
|
- `summary`
|
||||||
|
- `operator_description`
|
||||||
|
- `detectability_class`
|
||||||
|
- `evaluation_strategy`
|
||||||
|
- `evidence_archetypes[]`
|
||||||
|
- `artifact_suitability`
|
||||||
|
- `historical_status`
|
||||||
|
- **Fields when unresolved**:
|
||||||
|
- `reason_code` — stable failure vocabulary such as `missing_binding`, `unsupported_provider`, `unsupported_consumer_context`, or `insufficient_context`
|
||||||
|
- `binding_context`
|
||||||
|
- **Fields when ambiguous**:
|
||||||
|
- `reason_code` — stable failure vocabulary, including `ambiguous_binding`
|
||||||
|
- `candidate_control_keys[]`
|
||||||
|
- `binding_context`
|
||||||
|
- **Validation rules**:
|
||||||
|
- `resolved` returns exactly one canonical control.
|
||||||
|
- All supplied discriminator inputs must narrow resolution together; the resolver must not ignore a provided field to force a match.
|
||||||
|
- `ambiguous` returns no guessed winner.
|
||||||
|
- `unresolved` returns no local fallback label.
|
||||||
|
|
||||||
|
## Supporting Classifications
|
||||||
|
|
||||||
|
### DetectabilityClass
|
||||||
|
|
||||||
|
- `direct_technical`
|
||||||
|
- `indirect_technical`
|
||||||
|
- `workflow_attested`
|
||||||
|
- `external_evidence_only`
|
||||||
|
|
||||||
|
### EvaluationStrategy
|
||||||
|
|
||||||
|
- `state_evaluated`
|
||||||
|
- `signal_inferred`
|
||||||
|
- `workflow_confirmed`
|
||||||
|
- `externally_attested`
|
||||||
|
|
||||||
|
### EvidenceArchetype
|
||||||
|
|
||||||
|
- `configuration_snapshot`
|
||||||
|
- `execution_result`
|
||||||
|
- `policy_or_assignment_summary`
|
||||||
|
- `operator_attestation`
|
||||||
|
- `external_artifact_reference`
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `CanonicalControlDefinition` has many `MicrosoftSubjectBinding` records.
|
||||||
|
- One `MicrosoftSubjectBinding` references exactly one canonical control.
|
||||||
|
- One governed subject or signal context may resolve to one control or to an explicit ambiguous set.
|
||||||
|
- Existing governance consumers remain the owners of their own records and read models; they do not become child entities of the canonical catalog.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
### CanonicalControlDefinition lifecycle
|
||||||
|
|
||||||
|
- `active`: valid for new bindings and downstream use
|
||||||
|
- `retired`: historical references remain resolvable, but new adoption should stop unless explicitly allowed
|
||||||
|
|
||||||
|
### Resolution lifecycle
|
||||||
|
|
||||||
|
- `resolved`: downstream consumer may use canonical metadata directly
|
||||||
|
- `unresolved`: downstream consumer must surface or log explicit absence rather than invent local meaning
|
||||||
|
- `ambiguous`: downstream consumer must stop and preserve explicit ambiguity until the binding model is clarified
|
||||||
|
|
||||||
|
## Rollout Model
|
||||||
|
|
||||||
|
- The first slice keeps the catalog seeded in code and consumed through the resolver.
|
||||||
|
- Broad persistence of `canonical_control_key` on downstream entities is deferred.
|
||||||
|
- First-slice adoption is read-through and bounded to findings-derived evidence composition and tenant review composition.
|
||||||
250
specs/236-canonical-control-catalog-foundation/plan.md
Normal file
250
specs/236-canonical-control-catalog-foundation/plan.md
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
# Implementation Plan: Canonical Control Catalog Foundation
|
||||||
|
|
||||||
|
**Branch**: `236-canonical-control-catalog-foundation` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/236-canonical-control-catalog-foundation/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the slice intentionally narrow. It introduces one product-seeded canonical control catalog plus one shared resolver contract, then adopts that contract only in findings-derived evidence composition and tenant review composition without adding operator CRUD, Graph sync, or a new operator-facing surface.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a config-seeded canonical control catalog in `apps/platform/config/canonical_controls.php` plus a small `App\Support\Governance\Controls` value-object and resolver layer so the same governance objective resolves to one stable control identity, one honest detectability story, and one provider-neutral vocabulary. The implementation will keep Microsoft workload, subject-family, and signal mappings as secondary provider-owned bindings, expose explicit `resolved`, `unresolved`, and `ambiguous` outcomes, and adopt the shared contract only in the existing findings-derived evidence composition and tenant review composition paths instead of widening into baseline, exception, report, or review-pack consumers in this slice.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Primary Dependencies**: existing governance support types under `App\Support\Governance`, `EvidenceSnapshotResolver`, `EvidenceSnapshotService`, `FindingsSummarySource`, `TenantReviewComposer`, `TenantReviewSectionFactory`, `TenantReviewService`, Pest v4
|
||||||
|
**Storage**: Existing PostgreSQL tables for downstream evidence and tenant review records; product-seeded in-repo config for canonical control definitions and Microsoft bindings
|
||||||
|
**Testing**: Pest v4 unit and feature tests through Laravel Sail
|
||||||
|
**Validation Lanes**: `fast-feedback`, `confidence`
|
||||||
|
**Target Platform**: Laravel admin web application running in Sail with existing `/admin` and `/admin/t/{tenant}` surfaces
|
||||||
|
**Project Type**: Monorepo with one Laravel runtime in `apps/platform` and spec artifacts at repository root
|
||||||
|
**Performance Goals**: Keep catalog lookup deterministic and in-process, add no outbound provider calls, and avoid new high-cardinality or repeated per-item resolver work in evidence or tenant review composition
|
||||||
|
**Constraints**: No new Graph calls, no sync job, no DB-backed control authoring UI, no new operator-facing page, no new persistence table, and no provider-specific vocabulary leaking into platform-core control identity
|
||||||
|
**Scale/Scope**: One config-backed catalog, one shared resolver, one bounded Microsoft binding family, two first-slice downstream adoption paths, and focused governance foundation unit plus feature tests
|
||||||
|
|
||||||
|
## Filament v5 Implementation Contract
|
||||||
|
|
||||||
|
- **Livewire v4.0+ compliance**: Preserved. This slice changes shared services and value objects 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.
|
||||||
|
- **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 future UI work introduces registered assets.
|
||||||
|
- **Testing plan**: Prove the slice with focused Pest unit coverage for catalog and resolver rules plus focused feature coverage for logical resolution, findings-derived evidence composition, and tenant review composition.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: no operator-facing surface change
|
||||||
|
- **Native vs custom classification summary**: `N/A`
|
||||||
|
- **Shared-family relevance**: evidence viewers, tenant review detail composition, governance summaries
|
||||||
|
- **State layers in scope**: detail
|
||||||
|
- **Handling modes by drift class or surface**: `report-only`
|
||||||
|
- **Repository-signal treatment**: `report-only`
|
||||||
|
- **Special surface test profiles**: `standard-native-filament`
|
||||||
|
- **Required tests or manual smoke**: `functional-core`
|
||||||
|
- **Exception path and spread control**: none planned; any later UI adoption stays in a follow-through slice
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: findings-derived evidence composition, evidence snapshot lookup, tenant review composition, tenant review section rendering inputs
|
||||||
|
- **Shared abstractions reused**: existing evidence composition paths, existing tenant review composition paths, existing governance support types, new shared `CanonicalControlCatalog` and `CanonicalControlResolver`
|
||||||
|
- **New abstraction introduced? why?**: yes. A bounded catalog plus resolver layer is required because existing builders only know provider subjects or local evidence context and cannot safely share one control identity.
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: existing builders are sufficient for surface-specific formatting, but insufficient for cross-domain control identity, detectability semantics, and provider-neutral vocabulary.
|
||||||
|
- **Bounded deviation / spread control**: none. Downstream consumers must call the shared resolver rather than add local registries or fallback labels.
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Provider-owned seams**: Microsoft workload labels, subject-family identifiers, signal keys, supported-context bindings
|
||||||
|
- **Platform-core seams**: canonical control key, control domain, control subdomain, control class, detectability class, evaluation strategy, evidence archetypes, artifact suitability, historical status
|
||||||
|
- **Neutral platform terms / contracts preserved**: canonical control, provider binding, governed subject, detectability class, evaluation strategy, evidence archetype, artifact suitability
|
||||||
|
- **Retained provider-specific semantics and why**: Microsoft binding metadata remains provider-specific because the current product truth is Microsoft-first, but it remains secondary to the canonical control identity.
|
||||||
|
- **Bounded extraction or follow-up path**: none in this slice; future provider expansion can layer on the same binding model if and when a second concrete provider exists
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design: still passed with no new persistence, no new operator surface, no Graph path, and no auth-plane drift.*
|
||||||
|
|
||||||
|
| Gate | Status | Plan Notes |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Inventory-first / read-write separation | PASS | This slice is read-focused and in-process only. No new write, preview, or operator mutation flow is introduced. |
|
||||||
|
| RBAC, workspace isolation, tenant isolation | PASS | No new route or capability is added. Evidence and tenant review authorization remain the guarding surfaces for first-slice consumer metadata. |
|
||||||
|
| Run observability / Ops-UX lifecycle | PASS | No new `OperationRun` type is introduced. Existing evidence and tenant review operation semantics remain unchanged. |
|
||||||
|
| Shared pattern first | PASS | Evidence and tenant review builders remain the surface-specific composition paths; the shared catalog and resolver provide the missing control identity only. |
|
||||||
|
| Proportionality / no premature abstraction | PASS | One catalog and one resolver are the narrowest correct shared layer. No DB CRUD, no plugin framework, and no new persistence are introduced. |
|
||||||
|
| Persisted truth / behavioral state | PASS | No new table, entity, or lifecycle state is introduced. First-slice adoption is read-through only. |
|
||||||
|
| Provider boundary | PASS | Microsoft semantics remain secondary binding metadata and do not replace the platform-core control vocabulary. |
|
||||||
|
| Filament v5 / Livewire v4 contract | PASS | No new Filament surfaces or actions are added, and provider registration remains in `bootstrap/providers.php`. |
|
||||||
|
| 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 catalog and resolver semantics; `Feature` for findings-derived evidence composition, logical resolution, and tenant review composition
|
||||||
|
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The core risk is semantic drift, not browser behavior. Unit tests prove deterministic catalog and binding rules; feature tests prove first-slice consumers use the shared contract instead of local labels.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Minimal. Use config-seeded catalog fixtures and existing evidence or tenant review factories only where the downstream consumer proof needs persisted context.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: No. The catalog remains config-backed and in-process by default.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: `standard-native-filament` relief; the slice does not add or materially refactor a Filament screen
|
||||||
|
- **Closing validation and reviewer handoff**: Reviewers should verify that no Graph client or sync job changed, that first-slice adoption is limited to findings-derived evidence and tenant review composition, that unresolved and ambiguous outcomes never guess, and that provider-specific labels never replace canonical control vocabulary.
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected
|
||||||
|
- **Review-stop questions**: Did any change widen consumer adoption beyond the intended first slice? Did any change introduce Graph or sync behavior? Did any feature-local control registry or fallback label appear? Did any contract field drift from the data model or seeded metadata?
|
||||||
|
- **Escalation path**: `document-in-feature`
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
- **Why no dedicated follow-up spec is needed**: The slice is a bounded semantic foundation. Only future consumer expansion or a second provider would justify a wider follow-up spec.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/236-canonical-control-catalog-foundation/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── canonical-control-catalog.logical.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ ├── EvidenceSnapshot.php
|
||||||
|
│ │ ├── EvidenceSnapshotItem.php
|
||||||
|
│ │ └── TenantReview.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ ├── Evidence/
|
||||||
|
│ │ │ ├── EvidenceSnapshotResolver.php
|
||||||
|
│ │ │ ├── EvidenceSnapshotService.php
|
||||||
|
│ │ │ └── Sources/
|
||||||
|
│ │ │ └── FindingsSummarySource.php
|
||||||
|
│ │ └── TenantReviews/
|
||||||
|
│ │ ├── TenantReviewComposer.php
|
||||||
|
│ │ ├── TenantReviewSectionFactory.php
|
||||||
|
│ │ └── TenantReviewService.php
|
||||||
|
│ └── Support/
|
||||||
|
│ └── Governance/
|
||||||
|
│ ├── GovernanceDomainKey.php
|
||||||
|
│ └── Controls/
|
||||||
|
├── config/
|
||||||
|
│ └── canonical_controls.php
|
||||||
|
└── tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Evidence/
|
||||||
|
│ │ └── EvidenceSnapshotCanonicalControlReferenceTest.php
|
||||||
|
│ ├── Governance/
|
||||||
|
│ │ └── CanonicalControlResolutionIntegrationTest.php
|
||||||
|
│ └── TenantReview/
|
||||||
|
│ └── TenantReviewCanonicalControlReferenceTest.php
|
||||||
|
└── Unit/
|
||||||
|
└── Governance/
|
||||||
|
├── CanonicalControlCatalogTest.php
|
||||||
|
└── CanonicalControlResolverTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the slice entirely inside the existing Laravel runtime in `apps/platform`. The new structure is limited to one config-backed seed file and one small `App\Support\Governance\Controls` namespace, while first-slice consumer adoption stays inside existing Evidence and TenantReview services plus focused Pest tests.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitutional violation is planned. No complexity exception is currently required.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| — | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: The same governance objective is still described differently across findings-derived evidence and tenant review composition, which prevents stable control identity and honest detectability semantics.
|
||||||
|
- **Existing structure is insufficient because**: current governed-subject and workload metadata explain provider context, not the higher-order control objective or what kind of proof the product can honestly claim.
|
||||||
|
- **Narrowest correct implementation**: add one config-backed canonical control catalog plus one shared resolver and adopt it only in findings-derived evidence composition and tenant review composition.
|
||||||
|
- **Ownership cost created**: maintain the seed catalog, keep binding rules deterministic, and preserve regression tests that block local control-family drift.
|
||||||
|
- **Alternative intentionally rejected**: feature-local mappings and a DB-backed control authoring system. The first preserves fragmentation; the second imports unnecessary lifecycle, UI, and persistence complexity before the foundation is proven.
|
||||||
|
- **Release truth**: current-release truth with deliberate preparation for later consumer expansion
|
||||||
|
|
||||||
|
## Phase 0 Research Summary
|
||||||
|
|
||||||
|
- The first catalog should be product-seeded and config-backed, not DB-managed.
|
||||||
|
- Platform-core canonical controls must remain separate from provider-owned Microsoft bindings.
|
||||||
|
- Ambiguity must resolve as explicit `ambiguous`, never as a guessed winner.
|
||||||
|
- Detectability, evaluation strategy, and evidence archetypes belong directly on the control definition.
|
||||||
|
- First-slice adoption should be read-through rather than persistence-first.
|
||||||
|
- The seed catalog should stay bounded to a small set of high-value governance control families.
|
||||||
|
|
||||||
|
## Phase 1 Design Summary
|
||||||
|
|
||||||
|
- `research.md` records the architectural decisions that keep the slice narrow and provider-neutral at the control core.
|
||||||
|
- `data-model.md` defines the three core shapes: `CanonicalControlDefinition`, `MicrosoftSubjectBinding`, and `CanonicalControlResolutionResult`.
|
||||||
|
- `contracts/canonical-control-catalog.logical.openapi.yaml` defines the shared internal contract for catalog listing and control resolution.
|
||||||
|
- `quickstart.md` defines the narrow validation order and the intended code areas for the first slice.
|
||||||
|
- `tasks.md` sequences the work from seed catalog and resolver foundation through findings-derived evidence and tenant review adoption.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
Run after artifact generation:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Seed the canonical control catalog
|
||||||
|
|
||||||
|
**Goal**: Create one authoritative, product-seeded catalog with stable control keys and complete control metadata.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| A.1 | `apps/platform/config/canonical_controls.php` | Add the bounded canonical control seed catalog and Microsoft binding metadata. |
|
||||||
|
| A.2 | `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php` and related enums/value objects | Model canonical control metadata, detectability classes, evaluation strategies, evidence archetypes, artifact suitability, and historical status. |
|
||||||
|
| A.3 | `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` | Load, validate, and expose stable control definitions and binding metadata deterministically. |
|
||||||
|
|
||||||
|
### Phase B — Implement provider-owned binding and shared resolution semantics
|
||||||
|
|
||||||
|
**Goal**: Resolve canonical controls through one shared contract without letting Microsoft metadata become the control model.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| B.1 | `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php` | Model Microsoft workload, subject-family, signal, and supported-context binding metadata. |
|
||||||
|
| B.2 | `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php` and `CanonicalControlResolutionResult.php` | Define the shared request and response primitives for `resolved`, `unresolved`, and `ambiguous` outcomes. |
|
||||||
|
| B.3 | `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php` | Implement deterministic context-aware resolution, unresolved handling, and explicit ambiguity. |
|
||||||
|
|
||||||
|
### Phase C — Adopt the shared contract in the first-slice consumers
|
||||||
|
|
||||||
|
**Goal**: Move current-release consumer adoption onto the shared control contract without widening the slice.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| C.1 | `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php` and `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` | Resolve canonical control references inside findings-derived evidence composition. |
|
||||||
|
| C.2 | `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php` and `apps/platform/app/Models/EvidenceSnapshotItem.php` | Preserve transient resolved control metadata during evidence lookup and item payload consumption without introducing new canonical-control persistence ownership. |
|
||||||
|
| C.3 | `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `TenantReviewSectionFactory.php`, and `TenantReviewService.php` | Reuse the shared resolver during tenant review composition and keep persistence derived. |
|
||||||
|
|
||||||
|
### Phase D — Validate contract shape, scope discipline, and negative constraints
|
||||||
|
|
||||||
|
**Goal**: Prove semantic correctness while keeping the slice narrow.
|
||||||
|
|
||||||
|
| Step | File | Change |
|
||||||
|
|------|------|--------|
|
||||||
|
| D.1 | `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php` and `CanonicalControlResolverTest.php` | Prove stable keys, metadata completeness, multi-binding behavior, unresolved outcomes, ambiguity, and retired-control handling. |
|
||||||
|
| D.2 | `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php` | Prove the logical contract shape stays aligned to the seed catalog and resolver rules. |
|
||||||
|
| D.3 | `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php` and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php` | Prove first-slice consumers adopt the shared contract without local registries or fallback labels. |
|
||||||
|
| D.4 | `specs/236-canonical-control-catalog-foundation/quickstart.md` and `tasks.md` | Keep validation commands, paths, and no-Graph/no-sync guardrails explicit. |
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
- **Provider-shaped drift**: Microsoft labels may accidentally become the canonical vocabulary. Mitigation: keep canonical control definitions and bindings structurally separate and test for provider-neutral keys and labels.
|
||||||
|
- **Consumer-scope drift**: It is easy to widen adoption into baseline, exception, or report surfaces prematurely. Mitigation: keep first-slice scope explicitly limited to findings-derived evidence and tenant review composition in the plan, spec, tasks, and validation notes.
|
||||||
|
- **Contract-shape drift**: The contract, data model, and seed metadata can diverge. Mitigation: keep the logical contract small, test it directly, and align fields such as `operator_description`, `binding_context`, and supported contexts explicitly.
|
||||||
|
- **Graph creep**: Future-looking catalog work can attract provider sync ideas too early. Mitigation: keep a documented no-Graph/no-sync guardrail in tasks and review focus.
|
||||||
|
|
||||||
|
## 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 Graph path, and no new operation type. The plan, research, data model, quickstart, contract, and tasks now align on one config-seeded catalog, one shared resolver, one provider-boundary rule, and one bounded first-slice consumer scope.
|
||||||
68
specs/236-canonical-control-catalog-foundation/quickstart.md
Normal file
68
specs/236-canonical-control-catalog-foundation/quickstart.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Quickstart: Canonical Control Catalog Foundation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the first canonical control core without introducing framework overlays, operator CRUD, or new provider runtime machinery.
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
1. Add the product-seeded canonical control registry and the supporting value objects.
|
||||||
|
2. Add provider-owned Microsoft subject and signal bindings.
|
||||||
|
3. Implement the shared resolution contract with explicit `resolved`, `unresolved`, and `ambiguous` outcomes.
|
||||||
|
4. Wire a bounded first-slice set of governance consumers to the shared contract.
|
||||||
|
5. Add focused unit and feature coverage proving convergence and ambiguity handling.
|
||||||
|
|
||||||
|
## Suggested Code Areas
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/app/Support/Governance/Controls/
|
||||||
|
apps/platform/config/
|
||||||
|
apps/platform/app/Services/Evidence/
|
||||||
|
apps/platform/app/Services/TenantReviews/
|
||||||
|
apps/platform/tests/Unit/Governance/
|
||||||
|
apps/platform/tests/Feature/Governance/
|
||||||
|
apps/platform/tests/Feature/Evidence/
|
||||||
|
apps/platform/tests/Feature/TenantReview/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
Run the narrowest proving lane first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the bounded integration proof:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.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 the control catalog remains provider-neutral at its core.
|
||||||
|
- Confirm Microsoft bindings are secondary metadata only.
|
||||||
|
- Confirm first-slice evidence and tenant review consumers do not invent feature-local control-family wording.
|
||||||
|
- Confirm ambiguity is explicit and never guessed.
|
||||||
|
- Confirm no Graph path or provider sync job slipped into the slice.
|
||||||
|
- Confirm no broad persistence or authoring UI slipped into the first slice.
|
||||||
|
|
||||||
|
## Guardrail Close-Out
|
||||||
|
|
||||||
|
- Validation completed:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- Guardrails checked:
|
||||||
|
- No Graph client change.
|
||||||
|
- No `config/graph_contracts.php` change.
|
||||||
|
- No provider sync job.
|
||||||
|
- No feature-local control-family fallback or workload-first primary control vocabulary in the touched evidence and tenant review adoption paths.
|
||||||
|
- Bounded follow-up: none for this slice.
|
||||||
49
specs/236-canonical-control-catalog-foundation/research.md
Normal file
49
specs/236-canonical-control-catalog-foundation/research.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Research: Canonical Control Catalog Foundation
|
||||||
|
|
||||||
|
## Decision 1: Keep the first catalog product-seeded and config-backed
|
||||||
|
|
||||||
|
- **Decision**: Model the first canonical control catalog as a product-seeded registry in repository configuration plus narrow value objects and resolvers, not as an operator-managed DB CRUD domain.
|
||||||
|
- **Rationale**: The current release needs one stable control identity more than it needs authoring workflow, lifecycle UI, or workspace-specific customization. A config-backed seed catalog keeps the control core small, reviewable, versioned with the code, and easy to exercise across multiple governance consumers.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- DB-backed control management UI: rejected because the current release has no operator workflow that requires live authoring, approval, archiving, or per-workspace overrides.
|
||||||
|
- Feature-local arrays inside each consumer: rejected because that would preserve the current semantic fragmentation.
|
||||||
|
|
||||||
|
## Decision 2: Separate platform-core control definitions from provider-owned Microsoft bindings
|
||||||
|
|
||||||
|
- **Decision**: Canonical control identity, taxonomy, detectability, evaluation semantics, and evidence suitability stay platform-core, while Microsoft workload, subject-family, and signal relationships remain provider-owned binding metadata.
|
||||||
|
- **Rationale**: The product is Microsoft-first today, but this spec exists partly to stop Microsoft semantics from becoming silent platform truth. The separation keeps the catalog framework-neutral and provider-neutral without inventing a speculative multi-provider runtime.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Use Microsoft subject identifiers as the primary control key: rejected because it would make the provider the platform core.
|
||||||
|
- Create a generic provider-plugin framework now: rejected because there is only one real provider case today.
|
||||||
|
|
||||||
|
## Decision 3: Make ambiguity explicit in the shared resolution contract
|
||||||
|
|
||||||
|
- **Decision**: The shared resolver returns explicit `resolved`, `unresolved`, or `ambiguous` outcomes instead of guessing when one subject or signal could imply multiple controls.
|
||||||
|
- **Rationale**: A guessed mapping would silently misclassify governance meaning and poison later findings, review outputs, or evidence narratives. Explicit ambiguity is safer and easier to test.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Always return the first matching control: rejected because ordering would become hidden truth.
|
||||||
|
- Allow consumers to choose different local fallback rules: rejected because it would recreate semantic drift.
|
||||||
|
|
||||||
|
## Decision 4: Encode detectability, evaluation strategy, and evidence archetypes directly on each control
|
||||||
|
|
||||||
|
- **Decision**: Each canonical control definition carries detectability class, evaluation strategy, evidence archetypes, and artifact suitability instead of leaving those semantics to later presentation layers.
|
||||||
|
- **Rationale**: The control core must explain what the product can prove, partially infer, attest, or only reference externally. Deferring that meaning to later overlays would force downstream consumers to invent their own truth.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- One generic verification flag: rejected because it collapses materially different control types into one misleading boolean.
|
||||||
|
- Consumer-specific interpretation rules: rejected because those rules would diverge immediately.
|
||||||
|
|
||||||
|
## Decision 5: Keep first-slice consumer adoption derived rather than persistence-first
|
||||||
|
|
||||||
|
- **Decision**: First-slice consumers resolve canonical control metadata on read through the shared contract instead of requiring immediate schema expansion across baseline, finding, evidence, exception, and review records.
|
||||||
|
- **Rationale**: The current need is control convergence, not a broad storage migration. Derived adoption proves the catalog against real workflows while keeping rollout narrow.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Add `canonical_control_key` columns everywhere up front: rejected because it forces a broad migration before the model is proven.
|
||||||
|
- Leave all consumers untouched until a later reporting slice: rejected because then the catalog would exist without proving cross-domain value.
|
||||||
|
|
||||||
|
## Decision 6: Start with a bounded seed catalog of high-value governance families
|
||||||
|
|
||||||
|
- **Decision**: Seed only the control families already implied by the current product and roadmap, such as strong authentication, conditional access, privileged access, endpoint hardening or compliance, sharing boundaries, audit retention, and delegated admin boundaries.
|
||||||
|
- **Rationale**: The goal is a reviewable bridge layer, not exhaustive coverage. A bounded seed catalog is easier to validate and keeps the spec proportional.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Exhaustive control library in the first release: rejected because it imports compliance-program scale before the control core is proven.
|
||||||
|
- Framework-shaped seeds such as CIS or NIS2 first: rejected because frameworks are downstream overlays, not the primary control ontology.
|
||||||
243
specs/236-canonical-control-catalog-foundation/spec.md
Normal file
243
specs/236-canonical-control-catalog-foundation/spec.md
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# Feature Specification: Canonical Control Catalog Foundation
|
||||||
|
|
||||||
|
**Feature Branch**: `236-canonical-control-catalog-foundation`
|
||||||
|
**Created**: 2026-04-24
|
||||||
|
**Status**: Approved
|
||||||
|
**Input**: User description: "Canonical Control Catalog Foundation"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: TenantPilot already has real governance workflows across baselines, drift, findings, evidence, exceptions, and review packs, but it still lacks one shared canonical control object that those workflows can point at.
|
||||||
|
- **Today's failure**: The same technical control objective can be expressed differently in baseline logic, finding summaries, evidence interpretation, and later framework discussions, which blurs what the control actually is versus which Microsoft subject, workload, or evidence item currently supports it.
|
||||||
|
- **User-visible improvement**: Governance artifacts can converge on one stable control identity and one honest detectability story instead of each surface inventing local control meaning.
|
||||||
|
- **Smallest enterprise-capable version**: Introduce a product-seeded canonical control catalog with stable control keys, control metadata, detectability and evaluation semantics, evidence archetypes, Microsoft subject bindings, and one shared resolution contract consumed by existing governance builders.
|
||||||
|
- **Explicit non-goals**: No certification engine, no framework-first catalog, no full NIS2/BSI/ISO/COBIT library, no operator-managed CRUD UI for controls, no posture scoring, no second artifact store, and no broad Microsoft-domain expansion.
|
||||||
|
- **Permanent complexity imported**: One canonical control registry, one subject-binding model, one shared control-resolution contract, a small metadata family for detectability and evaluation semantics, and focused regression coverage for consumers.
|
||||||
|
- **Why now**: The roadmap and spec candidates place this as the next strategic bridge between the shipped governance engine and later readiness or customer-review work.
|
||||||
|
- **Why not local**: A local label or mapping inside one feature would keep control meaning fragmented and force every downstream surface to keep duplicating the same semantics.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: New source-of-truth risk and taxonomy risk. Defense: the first slice stays product-seeded, narrow, framework-neutral, and avoids authoring UI or speculative multi-provider machinery.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace
|
||||||
|
- **Primary Routes**:
|
||||||
|
- No new standalone route is required in the foundation slice.
|
||||||
|
- First-slice consumers remain on their current surfaces, specifically evidence snapshots and tenant review composition paths. Findings continue to feed the existing evidence pipeline on their current path, and any tenant review inspection remains downstream of already composed review data rather than a separate adoption target in this slice.
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Canonical control definitions are product-seeded platform truth consumed safely within workspace-scoped governance workflows.
|
||||||
|
- Derived control references in the first slice remain owned by existing evidence snapshot and tenant review records that consume them. Findings remain feeder inputs rather than a direct canonical-control consumer surface in this slice.
|
||||||
|
- No new operator-managed tenant-owned entity is introduced in the first slice.
|
||||||
|
- **RBAC**:
|
||||||
|
- No new top-level capability is introduced for the first slice.
|
||||||
|
- Existing authorization on evidence and tenant review surfaces continues to gate any downstream control metadata shown through those surfaces in the first slice.
|
||||||
|
- The catalog foundation must not relax tenant or workspace isolation.
|
||||||
|
|
||||||
|
## 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)**: evidence viewers, tenant review composition, governance summaries, read-model composition
|
||||||
|
- **Systems touched**: findings-derived evidence composition, evidence snapshot composition, tenant review composition, and downstream inspection of already composed review data
|
||||||
|
- **Existing pattern(s) to extend**: existing governance summary builders and existing evidence or review composition paths
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: existing domain builders stay in place; they consume one new shared control-resolution contract rather than inventing local control labels
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: existing builders are sufficient for surface-specific formatting, but they are insufficient for cross-domain control identity because each builder currently has only local subject or evidence context
|
||||||
|
- **Allowed deviation and why**: none
|
||||||
|
- **Consistency impact**: control key, control label, detectability language, and evidence suitability semantics must remain identical wherever the shared control contract is consumed
|
||||||
|
- **Review focus**: reviewers should block any new consumer that bypasses the shared control catalog by inventing local control-family wording or workload-first labels
|
||||||
|
|
||||||
|
## 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**: control taxonomy, governed-subject binding, control-resolution semantics, downstream operator vocabulary derived from canonical controls
|
||||||
|
- **Neutral platform terms preserved or introduced**: canonical control, control domain, control subdomain, control class, detectability class, evaluation strategy, evidence archetype, governed subject, provider binding
|
||||||
|
- **Provider-specific semantics retained and why**: Microsoft workload, subject-family, and signal bindings remain provider-owned metadata because the current product truth is Microsoft-first
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: canonical control keys and primary control definitions remain framework-neutral and provider-neutral; Microsoft-specific bindings are attached as secondary metadata, not used as the primary control identity
|
||||||
|
- **Follow-up path**: none
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
N/A - no new operator-facing surface is required in the foundation slice. Existing surfaces may consume canonical control references through later adoption or small follow-through changes, but this spec does not add a new page, queue, or custom UI framework.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: yes
|
||||||
|
- **Current operator problem**: Operators, reviewers, and future customer-facing outputs do not yet have one stable answer to which control an artifact is about; the same objective can still be rephrased differently per workflow.
|
||||||
|
- **Existing structure is insufficient because**: governed-subject taxonomy explains what Microsoft object or subject family is in scope, but it does not define the higher-order control objective, its detectability class, or how evidence should be interpreted across domains.
|
||||||
|
- **Narrowest correct implementation**: use a product-seeded canonical control registry plus one shared resolution contract and keep the first adoption derived rather than introducing CRUD management or broad new persistence.
|
||||||
|
- **Ownership cost**: the seed catalog must be curated, binding rules must stay deterministic, and downstream consumer tests must prevent drift in control identity or detectability semantics.
|
||||||
|
- **Alternative intentionally rejected**: feature-local control labels and an immediate DB-backed authoring system were rejected because the former preserves fragmentation and the latter imports unnecessary lifecycle and UI complexity before the catalog proves itself.
|
||||||
|
- **Release truth**: current-release truth with deliberate preparation for later readiness and reporting overlays
|
||||||
|
|
||||||
|
### 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 primarily a deterministic catalog and resolution contract with a few bounded integration points; unit tests prove metadata and resolution rules, and focused feature tests prove downstream consumers do not fork control meaning locally
|
||||||
|
- **New or expanded test families**: targeted governance foundation tests only
|
||||||
|
- **Fixture / helper cost impact**: minimal; use seeded config or registry fixtures and existing baseline, finding, evidence, and review factories where integration coverage is needed
|
||||||
|
- **Heavy-family visibility / justification**: none; no browser or heavy-governance family is required for the first slice
|
||||||
|
- **Special surface test profile**: N/A
|
||||||
|
- **Standard-native relief or required special coverage**: ordinary feature coverage only
|
||||||
|
- **Reviewer handoff**: reviewers should confirm that lane choice stays narrow, no expensive shared helper defaults are introduced, and all downstream references come from the shared contract rather than local labels
|
||||||
|
- **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/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Resolve One Stable Control Identity (Priority: P1)
|
||||||
|
|
||||||
|
As an operator or reviewer, I want governance artifacts that describe the same control objective to resolve to one stable canonical control so the product stops explaining the same issue differently per feature.
|
||||||
|
|
||||||
|
**Why this priority**: This is the primary value of the foundation. Without stable control identity, later readiness, reporting, and customer review work will keep duplicating local semantics.
|
||||||
|
|
||||||
|
**Independent Test**: Resolve the same governance objective through at least two existing consumer contexts and confirm the shared contract returns the same canonical control key and metadata.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** two Microsoft subject families that represent the same governance objective, **When** the system resolves their canonical control references, **Then** both resolve to the same canonical control key and label.
|
||||||
|
2. **Given** a findings-derived evidence composition path and a tenant review consumer that point at the same governance objective, **When** both request canonical control metadata, **Then** both receive the same control identity and detectability semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Preserve Honest Detectability and Evidence Meaning (Priority: P1)
|
||||||
|
|
||||||
|
As an operator preparing a governance review, I want the platform to distinguish direct-technical controls from indirect, attested, or external-evidence-only controls so later outputs do not over-claim what TenantPilot can prove automatically.
|
||||||
|
|
||||||
|
**Why this priority**: Honest detectability is part of the product's trust contract. A canonical control layer that collapses all controls into one false verified or not-verified path would harm operator trust.
|
||||||
|
|
||||||
|
**Independent Test**: Inspect seed controls with different detectability classes and verify each one carries explicit evaluation and evidence semantics.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a seed control that is only workflow-attested or external-evidence-only, **When** the control is resolved, **Then** the metadata explicitly marks that detectability class instead of implying direct technical verification.
|
||||||
|
2. **Given** a seed control with multiple allowed evidence archetypes, **When** a downstream consumer requests suitability metadata, **Then** the response identifies which evidence forms are valid for that control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Add Microsoft Bindings Without Making Microsoft the Control Model (Priority: P2)
|
||||||
|
|
||||||
|
As a maintainer extending governance coverage, I want Microsoft workload and signal bindings to attach to canonical controls without turning service-specific labels into the platform's primary control vocabulary.
|
||||||
|
|
||||||
|
**Why this priority**: The first provider is Microsoft, but the platform core must not become silently Microsoft-shaped. This story protects the boundary while still enabling real current-release bindings.
|
||||||
|
|
||||||
|
**Independent Test**: Add or modify a Microsoft subject binding for a seeded control and confirm the canonical control definition stays unchanged while the binding metadata changes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a canonical control already exists for a governance objective, **When** a new Microsoft subject family is bound to it, **Then** the system reuses the existing canonical control key instead of creating a duplicate control definition.
|
||||||
|
2. **Given** provider-specific subject or signal metadata changes, **When** the binding is updated, **Then** the platform-core control definition remains stable and provider-neutral.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Prepare Later Readiness and Review Work Without Local Reinvention (Priority: P3)
|
||||||
|
|
||||||
|
As a product maintainer, I want the first-slice evidence and tenant review consumers to have one defined path to canonical control metadata so later work does not invent its own framework or workload-specific control objects.
|
||||||
|
|
||||||
|
**Why this priority**: This is the strategic bridge value of the spec. It keeps later slices smaller and prevents new semantic drift.
|
||||||
|
|
||||||
|
**Independent Test**: Prove that the first-slice evidence and tenant review consumers can request canonical control metadata through one shared contract without adding local control-family registries.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an evidence or tenant review consumer is in the first adoption slice, **When** it needs control metadata, **Then** it uses the shared canonical control contract rather than feature-local labels or registries.
|
||||||
|
2. **Given** a framework-specific readiness or reporting slice is planned later, **When** it references control meaning, **Then** the canonical catalog remains the primary control layer and any framework mapping remains secondary.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- One Microsoft subject family can plausibly map to more than one control objective.
|
||||||
|
- A canonical control is valid for review packs and evidence only, but not for direct baseline or drift evaluation.
|
||||||
|
- A control is retired for new use, but existing downstream references still point to its stable key.
|
||||||
|
- A downstream consumer asks for canonical control metadata without a valid subject binding.
|
||||||
|
- Two provider-owned bindings point to one canonical control while using different signal shapes.
|
||||||
|
- A future framework mapping attempts to redefine canonical control identity instead of layering on top of it.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This foundation does not add Microsoft Graph calls, destructive actions, or a new operator-facing run flow. The first slice remains read-focused and in-process. If later follow-through work introduces writes, runs, or new surfaces, those slices must define their own safety and observability contract explicitly.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature intentionally introduces one new canonical control taxonomy and one shared resolution contract because governed-subject vocabulary alone cannot safely carry control meaning. The first slice avoids DB-backed control authoring, avoids framework overlays, and keeps consumer adoption derived before persistence.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** This feature touches cross-cutting governance summaries plus first-slice evidence and tenant review consumers. Those consumers must continue to use their existing builders and presentation paths, but control identity and detectability semantics must come from the shared canonical control contract.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** The canonical control catalog is platform-core. Microsoft workload and signal bindings are provider-owned metadata. Provider-specific semantics must remain secondary and must not replace canonical control keys or vocabulary.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Coverage stays in narrow unit and feature lanes. No new heavy browser or broad surface family is justified for the first slice.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** Not applicable in the foundation slice because no new `OperationRun` is required.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** No authorization boundary changes are introduced. Existing capabilities continue to guard any consumer surfaces that later display canonical control metadata.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** The first slice does not add new badge families. If later consumers render detectability or suitability as badges, they must do so through centralized badge semantics in a follow-through slice.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The foundation slice does not require new Filament UI.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Canonical control vocabulary must remain stable across future consumer surfaces. Provider or workload names are secondary descriptors only.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** No new decision surface is added in the foundation slice.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** Not applicable in the foundation slice because no new operator-facing surface is added.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** Not applicable.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Not applicable in the foundation slice because there is no new operator-facing page.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature adds one semantic layer because direct domain-to-UI mapping is insufficient across baseline, finding, evidence, and review workflows without a shared control identity. The catalog and resolver must remain the single source for this meaning, and downstream tests must focus on business truth rather than thin wrappers.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-236-001 Authoritative canonical control catalog**: The system MUST maintain one authoritative canonical control catalog for the first slice with stable canonical control keys that are independent of provider identifiers, framework clause IDs, and individual workload payload shapes, and it MUST support internal listing of seeded control definitions for inspection and validation.
|
||||||
|
- **FR-236-002 Control definition metadata**: Each canonical control definition MUST include, at minimum, a stable key, canonical name, control domain, control subdomain, control class, descriptive summary, and operator-safe explanation of what the control is about.
|
||||||
|
- **FR-236-003 Detectability semantics**: Each canonical control definition MUST declare a detectability class that distinguishes at least direct-technical, indirect-technical, workflow-attested, and external-evidence-only controls.
|
||||||
|
- **FR-236-004 Evaluation semantics**: Each canonical control definition MUST declare an evaluation strategy that explains how the product should reason about the control without collapsing all controls into one universal compliant or non-compliant path.
|
||||||
|
- **FR-236-005 Evidence archetypes**: Each canonical control definition MUST declare at least one evidence archetype and MAY declare more than one valid evidence archetype.
|
||||||
|
- **FR-236-006 Artifact suitability**: Each canonical control definition MUST declare whether it is baseline-capable, drift-capable, finding-capable, exception-capable, evidence-capable, review-capable, and report-capable.
|
||||||
|
- **FR-236-007 Microsoft subject binding model**: The system MUST support provider-owned Microsoft bindings that connect one canonical control to one or more Microsoft subject families, workloads, or signal sources without redefining the control itself.
|
||||||
|
- **FR-236-008 Provider-neutral control identity**: Provider-specific subject metadata MUST NOT be the canonical control primary key or replace the provider-neutral control definition.
|
||||||
|
- **FR-236-009 Multi-binding support**: One canonical control MUST be able to bind to multiple Microsoft subject families or signals.
|
||||||
|
- **FR-236-010 Ambiguity handling**: If a governed subject or signal maps ambiguously to multiple canonical controls without an explicitly declared primary relationship for the current context, the resolver MUST fail deterministically rather than guessing.
|
||||||
|
- **FR-236-011 Shared resolution contract**: The platform MUST provide one shared resolution contract that lets downstream governance consumers request canonical control metadata using current governed-subject or signal context.
|
||||||
|
- **FR-236-012 Consumer convergence path**: Findings-derived evidence composition and tenant review consumers in scope for first adoption MUST be able to consume canonical control metadata through the shared contract instead of defining local control-family truth.
|
||||||
|
- **FR-236-013 Seed catalog breadth**: The first slice MUST ship with a bounded seed catalog covering a small set of high-value control families relevant to the current governance product, including strong authentication, conditional access, privileged access, sharing or boundary controls, endpoint hardening or compliance, audit retention, and delegated admin boundaries.
|
||||||
|
- **FR-236-014 No framework-first primary shape**: Framework overlays such as NIS2, BSI, ISO, COBIT, or CIS MUST NOT be the primary shape of the canonical catalog in the first slice.
|
||||||
|
- **FR-236-015 Honest non-direct coverage**: The system MUST represent controls that are not directly technically verifiable without implying that they are directly evaluated by the product.
|
||||||
|
- **FR-236-016 Stable historical reference**: A canonical control key once shipped MUST remain stable for downstream artifacts to reference it consistently, even if the control later becomes retired for new use.
|
||||||
|
- **FR-236-017 Missing binding failure safety**: If a downstream consumer requests canonical control metadata for a subject or signal with no valid binding, the system MUST return an explicit unresolved result rather than inventing a local fallback control label.
|
||||||
|
- **FR-236-018 Narrow rollout model**: The first slice MUST stay product-seeded and MUST NOT require operator-managed CRUD authoring for controls.
|
||||||
|
- **FR-236-019 No new Graph path**: The first slice MUST NOT introduce Microsoft Graph calls or a provider synchronization job for catalog resolution.
|
||||||
|
- **FR-236-020 Platform vocabulary**: Shared platform contracts introduced by this feature MUST use canonical control and governed-subject vocabulary rather than workload-specific or framework-specific names as their primary language.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Canonical Control Definition**: The product-owned description of one stable governance control objective, including its identity, taxonomy placement, detectability semantics, evaluation strategy, evidence archetypes, and suitability metadata.
|
||||||
|
- **Microsoft Subject Binding**: Provider-owned metadata that links Microsoft subject families, workloads, or signal sources to one canonical control without changing the control's primary identity.
|
||||||
|
- **Canonical Control Resolution Result**: The shared contract outcome that returns either a resolved canonical control reference or an explicit unresolved or ambiguous result for downstream consumers.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-236-001**: For every seed control in the first slice, 100% of catalog entries include control domain, subdomain, control class, detectability class, evaluation strategy, and at least one evidence archetype.
|
||||||
|
- **SC-236-002**: The same governance objective resolved through at least two targeted first-slice consumer contexts returns one identical canonical control key and label.
|
||||||
|
- **SC-236-003**: 100% of first-slice evidence and tenant review integrations use the shared canonical control contract and do not introduce feature-local control-family registries or fallback labels.
|
||||||
|
- **SC-236-004**: A new Microsoft subject binding for an already-modeled governance objective can be added without creating a duplicate canonical control definition.
|
||||||
256
specs/236-canonical-control-catalog-foundation/tasks.md
Normal file
256
specs/236-canonical-control-catalog-foundation/tasks.md
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
# Tasks: Canonical Control Catalog Foundation
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/236-canonical-control-catalog-foundation/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/canonical-control-catalog.logical.openapi.yaml`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Required. This feature changes shared governance semantics and downstream read-model composition, so Pest coverage must be added or extended in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`, `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`, `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`, and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`.
|
||||||
|
**Operations**: No new `OperationRun` type is introduced. Tasks that touch `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php` or `apps/platform/app/Services/TenantReviews/TenantReviewService.php` must preserve existing run ownership, notifications, and audit behavior instead of creating a new catalog-specific workflow.
|
||||||
|
**RBAC**: No new capability or route is introduced. Existing authorization on evidence and tenant review surfaces must remain tenant-safe, and any downstream control metadata must stay behind the current evidence and review authorization paths.
|
||||||
|
**UI Naming**: No new operator-facing action surface is added. If any evidence or review copy changes, canonical control vocabulary must be primary and provider or workload labels must remain secondary descriptors.
|
||||||
|
**Cross-Cutting Shared Pattern Reuse**: Extend the existing governance summary and composition paths in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` before introducing any feature-local control registry or formatter.
|
||||||
|
**Provider Boundary / Platform Core**: Platform-core control identity, taxonomy, detectability, and evidence suitability live in `apps/platform/app/Support/Governance/Controls/` and `apps/platform/config/canonical_controls.php`. Microsoft workload, subject-family, and signal metadata remain secondary binding data and must not become the primary key or vocabulary for canonical controls.
|
||||||
|
**UI / Surface Guardrails**: `N/A` for new surfaces. This slice is `report-only` for existing evidence and review composition surfaces and must not introduce a new page, wizard, or custom Filament contract.
|
||||||
|
**Filament UI Action Surfaces**: No new Filament Resource, RelationManager, or Page action is introduced. Existing global-search posture remains unchanged because no new Resource is added in this slice.
|
||||||
|
**Badges**: No new badge domain or badge-mapping family is introduced.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each slice stays independently testable. Recommended delivery order is `US1`, `US2`, `US3`, then `US4`, because consumer adoption depends on the shared catalog, metadata semantics, and provider-binding behavior being stable first.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [X] New or changed tests stay in the smallest honest family, and no heavy-governance or browser lane is introduced.
|
||||||
|
- [X] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; seeded config drives the catalog.
|
||||||
|
- [X] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [X] The declared surface test profile or `standard-native-filament` relief is explicit.
|
||||||
|
- [X] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Anchors)
|
||||||
|
|
||||||
|
**Purpose**: Lock the implementation anchors, consumer scope, and narrow proving commands before adding the canonical control core.
|
||||||
|
|
||||||
|
- [X] T001 [P] Verify the feature anchor inventory across `apps/platform/app/Support/Governance/`, `apps/platform/app/Services/Evidence/`, `apps/platform/app/Services/TenantReviews/`, and `apps/platform/config/canonical_controls.php`
|
||||||
|
- [X] T002 [P] Create the Spec 236 proving entry points in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`, `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`, `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`, and `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
|
||||||
|
- [X] T003 [P] Confirm the narrow validation commands and first-slice consumer scope in `specs/236-canonical-control-catalog-foundation/spec.md` and `specs/236-canonical-control-catalog-foundation/quickstart.md`
|
||||||
|
|
||||||
|
**Checkpoint**: Runtime anchors and proof entry points are fixed before implementation starts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Control Core)
|
||||||
|
|
||||||
|
**Purpose**: Establish the product-seeded catalog and shared resolution primitives that every user story depends on.
|
||||||
|
|
||||||
|
**CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T004 [P] Create the bounded seed-catalog configuration anchor in `apps/platform/config/canonical_controls.php`
|
||||||
|
- [X] T005 [P] Create canonical control metadata types in `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, `apps/platform/app/Support/Governance/Controls/DetectabilityClass.php`, `apps/platform/app/Support/Governance/Controls/EvaluationStrategy.php`, `apps/platform/app/Support/Governance/Controls/EvidenceArchetype.php`, and `apps/platform/app/Support/Governance/Controls/ArtifactSuitability.php`
|
||||||
|
- [X] T006 [P] Create provider-binding and resolution-contract primitives in `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php`
|
||||||
|
- [X] T007 [P] Create shared catalog and resolver service shells in `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`
|
||||||
|
- [X] T008 [P] Inventory first-slice downstream adoption seams in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php` so consumer adoption stays derived and local registries are forbidden
|
||||||
|
|
||||||
|
**Checkpoint**: The repo has one shared catalog namespace, one config-backed seed source, and one explicit downstream adoption boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Resolve One Stable Control Identity (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Resolve the same governance objective to one stable canonical control key and label across supported contexts.
|
||||||
|
|
||||||
|
**Independent Test**: Resolve the same objective through multiple subject families and consumer contexts and confirm the shared contract returns one identical canonical control key and canonical label.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [P] [US1] Add stable-key, canonical-label, and same-objective convergence coverage in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php` and `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`
|
||||||
|
- [X] T010 [P] [US1] Add shared logical-contract coverage for catalog listing and resolution shapes in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T011 [US1] Populate stable canonical control keys, names, domains, subdomains, classes, summaries, operator descriptions, and `historical_status` in `apps/platform/config/canonical_controls.php`
|
||||||
|
- [X] T012 [US1] Implement deterministic catalog loading, lookup, and historical-key stability in `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
|
||||||
|
- [X] T013 [US1] Implement shared canonical control resolution by provider, subject family, workload, signal, and consumer context in `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionRequest.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional and stable canonical control identity no longer depends on feature-local naming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Preserve Honest Detectability and Evidence Meaning (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Ensure each canonical control carries explicit detectability, evaluation, and evidence semantics so downstream consumers do not over-claim proof.
|
||||||
|
|
||||||
|
**Independent Test**: Resolve controls with direct, indirect, workflow-attested, and external-evidence-only semantics and confirm the returned metadata preserves those distinctions and allowed evidence forms.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T014 [P] [US2] Add detectability, evaluation-strategy, evidence-archetype, artifact-suitability, and required seed-family coverage in `apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php`
|
||||||
|
- [X] T015 [P] [US2] Add resolved-metadata contract coverage for honest detectability and suitability semantics in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [US2] Expand every seed definition with detectability, evaluation, evidence-archetype, and artifact-suitability metadata in `apps/platform/config/canonical_controls.php`
|
||||||
|
- [X] T017 [US2] Enforce metadata completeness and narrow validation failures in `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
|
||||||
|
- [X] T018 [US2] Expose downstream-safe detectability and suitability metadata through `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php` and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional and the shared contract carries honest proof semantics instead of a false universal verification model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Add Microsoft Bindings Without Making Microsoft the Control Model (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Attach Microsoft workload, subject-family, and signal bindings to canonical controls while keeping provider-neutral control identity primary.
|
||||||
|
|
||||||
|
**Independent Test**: Add or modify Microsoft binding metadata for a seeded control and confirm the canonical control definition stays stable, duplicate controls are not created, and ambiguous cases fail deterministically.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T019 [P] [US3] Add multi-binding, provider-neutral identity, unresolved, ambiguous, retired-control, and `historical_status` coverage in `apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php`
|
||||||
|
- [X] T020 [P] [US3] Add workload, signal, and context-primary integration coverage for no-guess resolution in `apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [US3] Model provider-owned Microsoft bindings, supported contexts, primary flags, and notes in `apps/platform/config/canonical_controls.php` and `apps/platform/app/Support/Governance/Controls/MicrosoftSubjectBinding.php`
|
||||||
|
- [X] T022 [US3] Implement binding selection, context-primary resolution, and deterministic unresolved or ambiguous reason codes in `apps/platform/app/Support/Governance/Controls/CanonicalControlResolver.php`
|
||||||
|
- [X] T023 [US3] Keep Microsoft metadata secondary and provider-neutral vocabulary primary across `apps/platform/app/Support/Governance/Controls/CanonicalControlDefinition.php`, `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`, and `apps/platform/app/Support/Governance/Controls/CanonicalControlResolutionResult.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional and Microsoft bindings extend coverage without becoming the control model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 - Prepare Later Readiness and Review Work Without Local Reinvention (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Give first-slice downstream consumers one shared path to canonical control metadata so evidence and review composition stop inventing local control families.
|
||||||
|
|
||||||
|
**Independent Test**: Generate evidence and compose a tenant review, then confirm both paths consume canonical control metadata through the shared resolver without adding a local registry or fallback label.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [X] T024 [P] [US4] Add evidence-snapshot control-reference coverage proving shared canonical-control contract use and no local fallback labels in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php`
|
||||||
|
- [X] T025 [P] [US4] Add tenant-review composition control-reference coverage proving shared canonical-control contract use and no local fallback labels in `apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T026 [US4] Resolve canonical control references inside findings-derived evidence composition in `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php` and `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
|
||||||
|
- [X] T027 [US4] Preserve transient shared control metadata on evidence lookup and snapshot item payload consumption without introducing new canonical-control persistence ownership in `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php` and `apps/platform/app/Models/EvidenceSnapshotItem.php`
|
||||||
|
- [X] T028 [US4] Reuse shared control resolution during review composition instead of local control wording in `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php` and `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`
|
||||||
|
- [X] T029 [US4] Keep tenant review orchestration derived and persistence-neutral while passing canonical control context through `apps/platform/app/Services/TenantReviews/TenantReviewService.php` and `apps/platform/app/Models/TenantReview.php`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 4 is independently functional and first-slice consumers have one shared control-resolution path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Validation
|
||||||
|
|
||||||
|
**Purpose**: Remove local semantic drift, run the narrow proving lanes, and close the feature with explicit guardrail notes.
|
||||||
|
|
||||||
|
- [X] T030 [P] Search `apps/platform/app/Support/Governance/Controls/`, `apps/platform/app/Services/Evidence/Sources/FindingsSummarySource.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotResolver.php`, `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`, `apps/platform/app/Services/TenantReviews/TenantReviewComposer.php`, `apps/platform/app/Services/TenantReviews/TenantReviewSectionFactory.php`, and `apps/platform/app/Services/TenantReviews/TenantReviewService.php` to confirm no feature-local control-family fallback, workload-first primary vocabulary, or framework-first primary control shape remains
|
||||||
|
- [X] T031 Run the fast-feedback unit lane from `specs/236-canonical-control-catalog-foundation/quickstart.md` with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Governance/CanonicalControlCatalogTest.php tests/Unit/Governance/CanonicalControlResolverTest.php`
|
||||||
|
- [X] T032 [P] Run the confidence feature lane from `specs/236-canonical-control-catalog-foundation/quickstart.md` with `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php`
|
||||||
|
- [X] T033 Run formatting for touched PHP and test files with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- [X] T034 Record the Guardrail close-out entry, validation commands, and any bounded follow-up note in `specs/236-canonical-control-catalog-foundation/quickstart.md` and the active PR description
|
||||||
|
- [X] T035 [P] Confirm this slice introduces no Graph client change, no `config/graph_contracts.php` change, and no provider sync job by searching `apps/platform/app/`, `apps/platform/config/graph_contracts.php`, and `apps/platform/app/Jobs/` before merge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all story work.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the MVP cut.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on User Story 1 because honest detectability metadata builds on the shared canonical identity and resolver contract.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 because provider-owned bindings must resolve the full canonical metadata set.
|
||||||
|
- **User Story 4 (Phase 6)**: Depends on User Story 1 through User Story 3 because downstream consumers should adopt the final shared catalog and binding behavior instead of an interim contract.
|
||||||
|
- **Polish (Phase 7)**: Depends on all completed story work.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: No dependency beyond Foundational.
|
||||||
|
- **US2**: Depends on US1 shared catalog and resolver behavior.
|
||||||
|
- **US3**: Depends on US1 and US2 shared identity plus metadata semantics.
|
||||||
|
- **US4**: Depends on US1, US2, and US3 to keep downstream consumer adoption on the final shared contract.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the story tests first and confirm they fail before implementation is considered complete.
|
||||||
|
- Keep the catalog product-seeded and in-repo; do not introduce DB-backed control authoring or migrations.
|
||||||
|
- Keep provider-specific binding metadata secondary to canonical control identity.
|
||||||
|
- Keep consumer adoption derived in evidence and review composition; do not add feature-local registries or fallback labels.
|
||||||
|
- Finish story-level validation before moving to the next dependent story.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- `T001`, `T002`, and `T003` can run in parallel during Setup.
|
||||||
|
- `T004`, `T005`, `T006`, `T007`, and `T008` can run in parallel during Foundational work.
|
||||||
|
- `T009` and `T010` can run in parallel for User Story 1 before `T011` through `T013`.
|
||||||
|
- `T014` and `T015` can run in parallel for User Story 2 before `T016` through `T018`.
|
||||||
|
- `T019` and `T020` can run in parallel for User Story 3 before `T021` through `T023`.
|
||||||
|
- `T024` and `T025` can run in parallel for User Story 4 before `T026` through `T029`.
|
||||||
|
- `T031`, `T032`, and `T033` can run in parallel during final validation once implementation is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 1 proof in parallel
|
||||||
|
T009 apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php
|
||||||
|
T010 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 2 proof in parallel
|
||||||
|
T014 apps/platform/tests/Unit/Governance/CanonicalControlCatalogTest.php
|
||||||
|
T015 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 3 proof in parallel
|
||||||
|
T019 apps/platform/tests/Unit/Governance/CanonicalControlResolverTest.php
|
||||||
|
T020 apps/platform/tests/Feature/Governance/CanonicalControlResolutionIntegrationTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 4
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# User Story 4 proof in parallel
|
||||||
|
T024 apps/platform/tests/Feature/Evidence/EvidenceSnapshotCanonicalControlReferenceTest.php
|
||||||
|
T025 apps/platform/tests/Feature/TenantReview/TenantReviewCanonicalControlReferenceTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Run `T031` before widening the slice.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Ship US1 to establish one stable canonical control identity and shared resolver contract.
|
||||||
|
2. Ship US2 to make detectability and evidence meaning explicit and honest.
|
||||||
|
3. Ship US3 to add Microsoft bindings without turning Microsoft semantics into platform truth.
|
||||||
|
4. Ship US4 to move evidence and tenant review composition onto the shared control contract.
|
||||||
|
5. Finish with final validation, formatting, and close-out notes from Phase 7.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
1. One contributor can prepare the config-backed catalog and metadata primitives while another prepares the dedicated test files.
|
||||||
|
2. After Foundation is complete, one contributor can take US1 or US2 while another prepares US3 test coverage against the shared resolver.
|
||||||
|
3. Once the shared resolver is stable, evidence adoption and tenant review adoption can proceed in parallel inside US4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks target different files or independent proof surfaces and can be worked in parallel once upstream blockers are cleared.
|
||||||
|
- `[US1]`, `[US2]`, `[US3]`, and `[US4]` map directly to the feature specification user stories.
|
||||||
|
- The logical contract already exists in `specs/236-canonical-control-catalog-foundation/contracts/canonical-control-catalog.logical.openapi.yaml`; implementation tasks keep runtime behavior aligned to that shape rather than creating a public HTTP surface.
|
||||||
|
- The suggested MVP scope is Phase 1 through Phase 3 only.
|
||||||
@ -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.
|
||||||
@ -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
|
||||||
115
specs/237-provider-boundary-hardening/data-model.md
Normal file
115
specs/237-provider-boundary-hardening/data-model.md
Normal 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.
|
||||||
253
specs/237-provider-boundary-hardening/plan.md
Normal file
253
specs/237-provider-boundary-hardening/plan.md
Normal 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.
|
||||||
84
specs/237-provider-boundary-hardening/quickstart.md
Normal file
84
specs/237-provider-boundary-hardening/quickstart.md
Normal 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.
|
||||||
42
specs/237-provider-boundary-hardening/research.md
Normal file
42
specs/237-provider-boundary-hardening/research.md
Normal 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.
|
||||||
235
specs/237-provider-boundary-hardening/spec.md
Normal file
235
specs/237-provider-boundary-hardening/spec.md
Normal 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.
|
||||||
226
specs/237-provider-boundary-hardening/tasks.md
Normal file
226
specs/237-provider-boundary-hardening/tasks.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user