# 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`.