# VS-07: Risk Alerts — Data Contract

**Version:** 1.0  
**Compliance:** Soft-Delete + Audit Trail  
**Migration:** `0036_risk_alerts.sql` (DbUp)

---

## Schema Design

### 1. `alert_thresholds` (Configuration — Mutable)

Portfolio-specific or organization-wide alert thresholds.

```sql
CREATE TABLE risk_management.alert_thresholds (
    threshold_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
    
    -- Threshold definition
    threshold_type VARCHAR(50) NOT NULL, -- 'concentration', 'var', 'volatility', 'singlePosition'
    threshold_name VARCHAR(255),
    threshold_value DECIMAL(5, 2),
    
    -- Escalation timing (minutes from initial)
    warn_at_minutes INT DEFAULT 2,
    critical_at_minutes INT DEFAULT 5,
    
    -- Status
    is_active BOOLEAN DEFAULT true,
    
    -- Audit
    created_by VARCHAR(100),
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    
    UNIQUE(portfolio_id, threshold_type)
);
```

### 2. `risk_alerts` (Soft-Delete — Alert Lifecycle)

Active and historical alerts. Current state filtered by `removed_at IS NULL`.

```sql
CREATE TABLE risk_management.risk_alerts (
    alert_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    portfolio_id UUID NOT NULL REFERENCES risk_management.portfolios(portfolio_id),
    threshold_id UUID NOT NULL REFERENCES risk_management.alert_thresholds(threshold_id),
    
    -- Alert definition
    threshold_type VARCHAR(50) NOT NULL,
    threshold_name VARCHAR(255),
    current_value DECIMAL(10, 4),
    threshold_value DECIMAL(10, 4),
    
    -- Lifecycle
    status VARCHAR(50) NOT NULL DEFAULT 'Initial', -- Initial, Warning, Critical, Resolved
    triggered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    warned_at TIMESTAMP NULL,
    critical_at TIMESTAMP NULL,
    resolved_at TIMESTAMP NULL,
    
    -- Soft-delete
    removed_at TIMESTAMP NULL,
    
    -- Message
    message TEXT,
    
    -- Audit
    correlation_id UUID,
    created_by VARCHAR(100),
    
    UNIQUE(portfolio_id, threshold_type, triggered_at, correlation_id),
    CHECK (removed_at IS NULL OR resolved_at IS NOT NULL)
);
```

### 3. `alert_escalations` (Append-Only — Audit)

Immutable record of all escalation events.

```sql
CREATE TABLE risk_management.alert_escalations (
    escalation_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alert_id UUID NOT NULL REFERENCES risk_management.risk_alerts(alert_id),
    
    -- Escalation
    from_status VARCHAR(50),
    to_status VARCHAR(50),
    escalated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    -- Reason
    reason VARCHAR(255), -- 'time_threshold', 'manual', 'critical_threshold'
    
    -- Audit
    triggered_by VARCHAR(100),
    correlation_id UUID
);
```

### 4. `alert_resolutions` (Append-Only — How Resolved)

Immutable record of alert resolution.

```sql
CREATE TABLE risk_management.alert_resolutions (
    resolution_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    alert_id UUID NOT NULL REFERENCES risk_management.risk_alerts(alert_id),
    
    -- Resolution
    resolved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    duration_minutes INT,
    
    -- How resolved
    resolution_type VARCHAR(50), -- 'auto', 'manual', 'threshold_back_to_safe'
    
    -- Notes
    resolution_notes TEXT,
    
    -- Audit
    resolved_by VARCHAR(100),
    correlation_id UUID
);
```

### 5. `alert_events` (Append-Only — Published Events)

Published to `shared.outbox`.

**Schema (JSONB in outbox.payload):**
```json
{
  "eventType": "RiskAlertTriggered|RiskAlertEscalated|RiskAlertResolved",
  "alertId": "550e8400-e29b-41d4-a716-446655440008",
  "portfolioId": "550e8400-e29b-41d4-a716-446655440001",
  "thresholdType": "concentration",
  "severity": "Warning",
  "currentValue": 65.2,
  "threshold": 60,
  "triggeredAt": "2026-08-05T10:30:00Z",
  "correlationId": "alert-2026-08-05-001"
}
```

---

## Query Patterns

### Current Active Alerts

```sql
SELECT
    alert_id,
    threshold_type,
    threshold_name,
    current_value,
    threshold_value,
    status,
    triggered_at,
    DATEDIFF(MINUTE, triggered_at, CURRENT_TIMESTAMP) as duration_minutes
FROM risk_management.risk_alerts
WHERE
    portfolio_id = @portfolioId
    AND removed_at IS NULL
    AND status IN ('Initial', 'Warning', 'Critical')
ORDER BY critical_at DESC NULLS LAST;
```

### Alert History (Last 30 Days)

```sql
SELECT
    alert_id,
    threshold_type,
    status,
    triggered_at,
    resolved_at,
    DATEDIFF(MINUTE, triggered_at, resolved_at) as duration_minutes
FROM risk_management.risk_alerts
WHERE
    portfolio_id = @portfolioId
    AND triggered_at >= CURRENT_DATE - INTERVAL 30 DAY
ORDER BY triggered_at DESC;
```

### Pending Escalations

```sql
SELECT
    a.alert_id,
    a.threshold_type,
    a.status,
    DATEDIFF(MINUTE, a.triggered_at, CURRENT_TIMESTAMP) as minutes_elapsed,
    t.warn_at_minutes,
    t.critical_at_minutes
FROM risk_management.risk_alerts a
JOIN risk_management.alert_thresholds t ON a.threshold_id = t.threshold_id
WHERE
    a.portfolio_id = @portfolioId
    AND a.removed_at IS NULL
    AND (
        (a.status = 'Initial' AND DATEDIFF(MINUTE, a.triggered_at, CURRENT_TIMESTAMP) >= t.warn_at_minutes)
        OR (a.status = 'Warning' AND DATEDIFF(MINUTE, a.triggered_at, CURRENT_TIMESTAMP) >= t.critical_at_minutes)
    )
ORDER BY a.triggered_at ASC;
```

### Idempotency Check

```sql
SELECT alert_id FROM risk_management.risk_alerts
WHERE
    portfolio_id = @portfolioId
    AND threshold_type = @thresholdType
    AND triggered_at >= CURRENT_TIMESTAMP - INTERVAL 5 MINUTE
    AND correlation_id = @correlationId
    AND removed_at IS NULL
LIMIT 1;
```

---

## Indexes

| Table | Columns | Reason |
|-------|---------|--------|
| alert_thresholds | (portfolio_id, is_active) | Fast active threshold lookup |
| risk_alerts | (portfolio_id, removed_at, status) | Fast active alert queries |
| risk_alerts | (triggered_at) | Fast escalation time checks |
| alert_escalations | (alert_id, escalated_at) | Fast escalation audit trail |
| alert_resolutions | (alert_id) | Fast resolution lookup |

---

## Pre-loaded Thresholds

On fresh install, create default thresholds per portfolio:

```sql
INSERT INTO risk_management.alert_thresholds VALUES
    (gen_random_uuid(), @portfolioId, 'concentration', 'Top-5 Holdings > 60%', 60.0, 2, 5, true, ...),
    (gen_random_uuid(), @portfolioId, 'var', 'VAR > 20% of Portfolio', 20.0, 2, 5, true, ...),
    (gen_random_uuid(), @portfolioId, 'volatility', 'Annualized Vol > 30%', 30.0, 3, 7, true, ...),
    (gen_random_uuid(), @portfolioId, 'singlePosition', 'Single Position > 40%', 40.0, 0, 5, true, ...);
```

---

## Escalation Job Logic (Hangfire)

**Scheduled:** Every 1 minute (after metric updates)

```pseudocode
FOR each active alert WHERE removed_at IS NULL:
    minutes_elapsed = NOW - triggered_at
    threshold = alert_thresholds[alert.threshold_type]
    
    IF status = 'Initial' AND minutes_elapsed >= threshold.warn_at_minutes:
        UPDATE risk_alerts SET status = 'Warning', warned_at = NOW
        INSERT alert_escalations(from_status='Initial', to_status='Warning')
        PUBLISH RiskAlertEscalated event
    
    ELSE IF status = 'Warning' AND minutes_elapsed >= threshold.critical_at_minutes:
        UPDATE risk_alerts SET status = 'Critical', critical_at = NOW
        INSERT alert_escalations(from_status='Warning', to_status='Critical')
        PUBLISH RiskAlertEscalated event
    
    ELSE IF metric_back_to_safe(alert.threshold_type, current_value):
        UPDATE risk_alerts SET status = 'Resolved', removed_at = NOW
        INSERT alert_resolutions(resolution_type='threshold_back_to_safe')
        PUBLISH RiskAlertResolved event
```

---

## Compliance

✅ **AGENTS.md v16.0:**
- Soft-delete (removed_at, not hard delete)
- Append-only audit (alert_escalations, alert_resolutions immutable)
- Correlation ID tracing (correlation_id)
- Idempotency key (portfolio_id + threshold_type + triggered_at + correlation_id)
- Full lifecycle tracked (triggered → escalated → resolved)

✅ **Alert Accuracy:**
- Thresholds configurable per portfolio
- Escalation timing deterministic (minutes from triggered_at)
- Automatic resolution when metric back to safe
- No false duplicates (UNIQUE constraint)

---

## Test Scenarios

| Test | Data Setup | Assertion |
|------|-----------|-----------|
| Threshold trigger | Metric exceeds threshold | Alert created with status=Initial |
| Escalation (2min) | Wait 2 minutes | Alert status → Warning, warned_at populated |
| Escalation (5min) | Wait 5 minutes | Alert status → Critical, critical_at populated |
| Auto-resolution | Metric back to safe | Alert status → Resolved, removed_at populated |
| Idempotency | Same breach twice in 5min | Single alert, no duplicate |
| Soft-delete | Resolve alert | Query filters correctly (removed_at IS NULL) |
| History query | Resolved alert | Appears in history, not current alerts |
