# Data Model — 099 Alerts v1 (Teams + Email)

Scope: **workspace-owned** configuration and **tenant-owned** delivery history. Deliveries are always tenant-scoped (`tenant_id` NOT NULL) and must only be listed/viewed for tenants the actor is entitled to (non-entitled tenants are treated as not found / 404 semantics).

## Entities

## AlertDestination (workspace-owned)

Purpose: reusable delivery target.

Fields:
- `id`
- `workspace_id` (FK, required)
- `name` (string, required)
- `type` (enum: `teams_webhook` | `email`, required)
- `is_enabled` (bool, default true)
- `config` (encrypted:array, required)
  - for `teams_webhook`: `{ "webhook_url": "https://..." }`
  - for `email`: `{ "recipients": ["a@example.com", "b@example.com"] }`
- timestamps

Validation rules:
- `name`: required, max length
- `type`: required, in allowed values
- `config.webhook_url`: required if type is teams; must be URL
- `config.recipients`: required if type is email; array of valid email addresses; must be non-empty

Security:
- `config` must never be logged or included in audit metadata.

## AlertRule (workspace-owned)

Purpose: routing + noise controls.

Fields:
- `id`
- `workspace_id` (FK, required)
- `name` (string, required)
- `is_enabled` (bool, default true)
- `event_type` (enum: `high_drift` | `compare_failed` | `sla_due`, required)
- `minimum_severity` (enum: `low` | `medium` | `high` | `critical`, required)
- `tenant_scope_mode` (enum: `all` | `allowlist`, required)
- `tenant_allowlist` (array, default empty)
- `cooldown_seconds` (int, nullable)
- `quiet_hours_enabled` (bool, default false)
- `quiet_hours_start` (string, e.g. `22:00`, nullable)
- `quiet_hours_end` (string, e.g. `06:00`, nullable)
- `quiet_hours_timezone` (IANA TZ string, nullable)
- timestamps

Validation rules:
- `name`: required
- `event_type`: required
- `minimum_severity`: required
- `tenant_allowlist`: required if `tenant_scope_mode=allowlist`
- quiet hours:
  - if enabled: start/end required, valid HH:MM, timezone optional

Notes:
- Quiet-hours timezone resolution:
  - rule timezone if set
  - else workspace timezone
  - else `config('app.timezone')`

## AlertRuleDestination (workspace-owned pivot)

Fields:
- `id`
- `workspace_id` (FK, required)
- `alert_rule_id` (FK)
- `alert_destination_id` (FK)
- timestamps

Constraints:
- Unique `(alert_rule_id, alert_destination_id)`

## AlertDelivery (tenant-owned history)

Purpose: immutable record of queued/sent/failed/deferred/suppressed deliveries.

Fields:
- `id`
- `workspace_id` (FK, required)
- `tenant_id` (FK, required)
- `alert_rule_id` (FK, required)
- `alert_destination_id` (FK, required)
- `fingerprint_hash` (string, required)
- `status` (enum: `queued` | `deferred` | `sent` | `failed` | `suppressed` | `canceled`)
- `send_after` (timestamp, nullable)
- `attempt_count` (int, default 0)
- `last_error_code` (string, nullable)
- `last_error_message` (string, nullable; sanitized)
- `sent_at` (timestamp, nullable)
- timestamps

Indexes:
- `(workspace_id, created_at)` for history listing
- `(workspace_id, status, send_after)` for dispatching due deferred deliveries
- `(workspace_id, alert_rule_id, fingerprint_hash)` for dedupe/cooldown checks

Retention:
- Default prune: 90 days.

## Relationships

- `AlertRule` hasMany `AlertRuleDestination` and belongsToMany `AlertDestination`.
- `AlertDestination` belongsToMany `AlertRule`.
- `AlertDelivery` belongsTo `AlertRule`, belongsTo `AlertDestination`, and belongsTo `Tenant`.

## State transitions

`AlertDelivery.status` transitions:
- `queued` → `sent` | `failed` | `suppressed` | `canceled`
- `deferred` → `queued` (when window opens) → `sent` | `failed` …

Terminal states: `sent`, `failed`, `suppressed`, `canceled`.
