OPER-47
ship repo_secrets_expiring reliability sensor (state 3.7)
Body
feature_id: FEAT-FORGE-RELIABILITY-LOOP
## Context
State 3.7 `repo_secrets_expiring` in the [[concepts/stuck-state-taxonomy]] is not yet covered by the Forge Reliability Loop. OPER-9A shipped in PR #129 (merged 2026-07-24 11:34Z) — it added:
- `packages/dispatcher/src/github-pat-expiry.ts` — `fetchPatExpiry()` reads the `github-authentication-token-expiration` header off a `/rate_limit` probe; fails soft on any degraded state.
- `apps/web/src/lib/pat-expiry.ts` — `selectPatExpiryBand()` returns T-60 / T-30 / T-7 / T-1 / expired with SEV-4 / SEV-3 / SEV-3 / SEV-2 / SEV-1.
- `apps/web/src/app/api/cron/pat-expiry-check/route.ts` — daily 08:00 UTC cron that Slack-alerts every team via `emitPipelineAlert`.
That covers Slack notification. This ticket adds the reliability-loop sensor so the same signal:
1. Appears in the `ReliabilityEvent` audit trail alongside every other sensor.
2. Files a Ledger ticket (not just a Slack alert) once the PAT hits SEV-2 (T-1) or SEV-1 (expired) so rotation has a work-item owner.
3. Runs at the same 15-min cadence as every other sensor without spamming — human dedup uses open ticket title.
## Deliverable
Add `apps/web/src/lib/forge/sensor-repo-secrets-expiring.ts`:
- Named export `repoSecretsExpiringSensor: Sensor` matching `types.ts`.
- Call `fetchPatExpiry()` from `@operant/dispatcher`.
- `degraded === true` → return `[]`.
- `noExpiration === true` → return `[]`.
- Else `daysUntilExpiry` + `selectPatExpiryBand`. If `band === null` → return `[]`.
- Emit one `SensorEvent` with `evidence = { secret: "GH_DISPATCH_PAT", expiresAt, daysRemaining, milestone, severity }`. autonomyDial: `pr` for SEV-2, `escalate` for SEV-1, `auto` (outcome `no_op`) for SEV-3 / SEV-4.
- SEV-2 or SEV-1 only:
- Dedup on `Task.title` starting with `Rotate GH_DISPATCH_PAT` and status NOT IN (DONE, CANCELED). Found → `no_op` + `dedupedAgainst`.
- Else create a `PLANNING_VALIDATED` ticket in team `cmrawxvxa0001obz9h9wjtha8`:
- `title`: `Rotate GH_DISPATCH_PAT (${milestone}, ${severity})`
- identifier via same `nextOperIdentifier()` pattern as `sensor-dispatcher-dominant-error.ts`
- body: escalation math + evidence + link to `docs/operations/pipeline-runbook.md#gh-dispatch-pat-rotation`
- labels: `[reliability, secrets, auto-created, forge-diagnostic]`
- priority: 0 for SEV-1, 1 for SEV-2
- size: `S`
- Register in `apps/web/src/lib/forge/registry.ts` at the end of the `SENSORS` array.
## Tests
`apps/web/src/lib/forge/__tests__/sensor-repo-secrets-expiring.test.ts`:
- degraded → 0 events
- noExpiration → 0 events
- days 90 → band=null → 0 events
- days 45 (SEV-4) → 1 event, no_op auto, no ticket
- days 20 (SEV-3) → 1 event, no_op auto, no ticket
- days 5 (SEV-3) → 1 event, no_op auto, no ticket
- days 1 (SEV-2) → 1 event, healed pr, ticket priority 1 created
- days -1 (SEV-1) → 1 event, healed escalate, ticket priority 0 created
- days 1 with existing open rotation ticket → no_op + dedupedAgainst
- dryRun=true → no ticket writes
Mock `fetchPatExpiry` via injected deps, same pattern as existing sensors.
## Constraints
- No new tables, no new migrations.
- Do not duplicate Slack alerts — OPER-9A owns that.
- Dedup on ticket title, not AlertHistory (AlertHistory rotates on PAT change; we want dedup that lasts until the human closes the ticket).
## Verification
`npx vitest run apps/web/src/lib/forge/__tests__/sensor-repo-secrets-expiring.test.ts` passes.
Attachments
Loading attachments…
Comments
Loading comments…