# Internal Contracts: Provider Permission Posture (Spec 104) **Date**: 2026-02-21 | **Branch**: `104-provider-permission-posture` This feature does NOT expose external HTTP APIs. All contracts are internal service interfaces. --- ## Contract 1: PermissionPostureFindingGenerator **Service**: `App\Services\PermissionPosture\PermissionPostureFindingGenerator` ### `generate(Tenant $tenant, array $permissionComparison, ?OperationRun $operationRun = null): PostureResult` **Input**: - `$tenant`: The tenant being evaluated - `$permissionComparison`: Output of `TenantPermissionService::compare()` — see schema below - `$operationRun`: Optional OperationRun for tracking (created by caller if not provided) **Input schema** (`$permissionComparison`): ```php array{ overall_status: 'granted'|'missing'|'error', permissions: array, status: 'granted'|'missing'|'error', details: ?array, }>, last_refreshed_at: ?string, } ``` **Output** (`PostureResult` value object): ```php PostureResult { int $findingsCreated, int $findingsResolved, int $findingsReopened, int $findingsUnchanged, int $errorsRecorded, int $postureScore, int $storedReportId, } ``` **Side effects**: 1. Creates/updates/resolves `Finding` records (type: `permission_posture`) 2. Creates a `StoredReport` (type: `permission_posture`) 3. Dispatches alert events for new/reopened findings via the alert pipeline **Idempotency**: Fingerprint-based. Same input produces same findings (no duplicates). --- ## Contract 2: PostureScoreCalculator **Service**: `App\Services\PermissionPosture\PostureScoreCalculator` ### `calculate(array $permissionComparison): int` **Input**: `$permissionComparison` — same schema as Contract 1 input. **Output**: Integer 0–100 representing `round(granted_count / required_count * 100)`. Returns 100 if `required_count` is 0. **Pure function**: No side effects, no DB access. --- ## Contract 3: GeneratePermissionPostureFindingsJob **Job**: `App\Jobs\GeneratePermissionPostureFindingsJob` ### Constructor ```php public function __construct( public readonly int $tenantId, public readonly array $permissionComparison, ) ``` ### `handle(PermissionPostureFindingGenerator $generator): void` **Flow**: 1. Load Tenant (fail if not found) 2. Skip if tenant has no active provider connection (FR-016) 3. Create OperationRun (`type = permission_posture_check`) 4. Call `$generator->generate($tenant, $permissionComparison, $run)` 5. Record summary counts on OperationRun 6. Complete OperationRun **Queue**: `default` (same queue as health checks) **Dispatch point**: `ProviderConnectionHealthCheckJob::handle()` after `TenantPermissionService::compare()` returns, when `$permissionComparison['overall_status'] !== 'error'`. --- ## Contract 4: Alert Event Schema (EVENT_PERMISSION_MISSING) **Event array** (passed to `AlertDispatchService::dispatchEvent()`): ```php [ 'event_type' => 'permission_missing', 'tenant_id' => int, 'severity' => 'low'|'medium'|'high'|'critical', 'fingerprint_key' => "permission_missing:{tenant_id}:{permission_key}", 'title' => "Missing permission: {permission_key}", 'body' => "Tenant {tenant_name} is missing {permission_key}. Blocked features: {feature_list}.", 'metadata' => [ 'permission_key' => string, 'permission_type' => string, 'blocked_features' => array, 'posture_score' => int, ], ] ``` **Produced by**: `PermissionPostureFindingGenerator` for each newly created or re-opened finding. **Not produced for**: Auto-resolved findings, existing unchanged findings, error findings. --- ## Contract 5: EvaluateAlertsJob Extension ### New method: `permissionMissingEvents(): array` **Query**: `Finding` where: - `finding_type = 'permission_posture'` - `status IN ('new')` (only new/re-opened, not acknowledged or resolved) - `severity` >= minimum severity threshold from alert rule - `created_at` or `updated_at` >= window start **Output**: Array of event arrays matching Contract 4 schema. --- ## Contract 6: StoredReport Retention ### `PruneStoredReportsCommand` (Artisan command) **Signature**: `stored-reports:prune {--days=90}` **Behavior**: Deletes `stored_reports` where `created_at < now() - days`. Outputs count of deleted records. **Schedule**: Daily via `routes/console.php`.