# 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<int, array{
        key: string,
        type: string,
        description: ?string,
        features: array<int, string>,
        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<string>,
        '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`.
