# VS-01: Identity and Roles Data Contract

**Slice:** VS-01 (ManageIdentityAndRoles)  
**Status:** 📋 SPECIFICATION  
**Version:** 1.0  
**Created:** 2026-08-04  

---

## Schema (3NF Write Model)

### identity.users (User Accounts)

**Purpose:** Immutable user record (append-only, PIT envelope)

```sql
CREATE TABLE identity.users (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- Business Keys (immutable)
    email VARCHAR(255) NOT NULL UNIQUE,
    email_hash VARCHAR(64) NOT NULL UNIQUE,  -- SHA-256 of email
    
    -- Authentication (write-once)
    password_hash VARCHAR(255) NOT NULL,  -- bcrypt, never changed after creation
    
    -- State
    status VARCHAR(20) NOT NULL DEFAULT 'active'
        CHECK (status IN ('active', 'inactive', 'suspended')),
    
    -- Temporal (PIT)
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    
    -- Revision Tracking
    revision INT NOT NULL DEFAULT 1,
    content_hash VARCHAR(64) NOT NULL,  -- SHA-256 of (email, status, updated_at)
    
    -- Audit
    created_by_user_id UUID REFERENCES identity.users(id),
    correlation_id VARCHAR(36) NOT NULL,
    
    -- Indexing
    CONSTRAINT email_lowercase CHECK (email = LOWER(email)),
    CONSTRAINT valid_email CHECK (email ~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$')
);

CREATE INDEX idx_users_email ON identity.users(email);
CREATE INDEX idx_users_status ON identity.users(status);
CREATE INDEX idx_users_published_at ON identity.users(published_at);
CREATE INDEX idx_users_created_by ON identity.users(created_by_user_id);
```

**Constraints:**
- ✅ email UNIQUE: Only one account per email per environment
- ✅ status IN ('active', 'inactive', 'suspended'): Enum validation
- ✅ published_at ≤ CURRENT_TIMESTAMP: Never future-dated
- ✅ created_at ≤ updated_at: Temporal order

**PIT (Point-in-Time) Query:**
```sql
SELECT * FROM identity.users
WHERE published_at <= @cutoff
  AND status = 'active'
ORDER BY created_at DESC;
```

---

### identity.roles (Role Definitions)

**Purpose:** Immutable, predefined roles (reference data)

```sql
CREATE TABLE identity.roles (
    id SERIAL PRIMARY KEY,
    name VARCHAR(50) NOT NULL UNIQUE,
    description VARCHAR(255),
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO identity.roles (name, description) VALUES
    ('Admin', 'Full system access'),
    ('Analyst', 'Read-only analysis'),
    ('Trader', 'Execute trades'),
    ('Viewer', 'Dashboard read-only');

-- Prevent deletion (immutable reference data)
CREATE TRIGGER prevent_role_deletion
BEFORE DELETE ON identity.roles
FOR EACH ROW
EXECUTE FUNCTION raise_immutability_error();
```

**Constraints:**
- ✅ name UNIQUE: One role per name
- ✅ Immutable: No INSERT/UPDATE/DELETE after initial load
- ✅ Predefined: Only 4 roles (Admin, Analyst, Trader, Viewer)

---

### identity.user_roles (User-Role Assignment)

**Purpose:** Many-to-many junction table (append-only)

```sql
CREATE TABLE identity.user_roles (
    id BIGSERIAL PRIMARY KEY,
    
    -- Foreign Keys
    user_id UUID NOT NULL REFERENCES identity.users(id),
    role_id INT NOT NULL REFERENCES identity.roles(id),
    
    -- Temporal
    assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    removed_at TIMESTAMP,  -- NULL = still assigned, NOT NULL = removed
    
    -- Audit
    assigned_by_user_id UUID REFERENCES identity.users(id),
    correlation_id VARCHAR(36) NOT NULL,
    
    -- Versioning (for CDC)
    revision INT NOT NULL DEFAULT 1,
    
    -- Constraints
    CONSTRAINT active_assignment CHECK (assigned_at <= published_at),
    CONSTRAINT valid_removal CHECK (removed_at IS NULL OR removed_at >= assigned_at),
    CONSTRAINT unique_active_role UNIQUE (user_id, role_id) WHERE removed_at IS NULL
);

CREATE INDEX idx_user_roles_user ON identity.user_roles(user_id);
CREATE INDEX idx_user_roles_role ON identity.user_roles(role_id);
CREATE INDEX idx_user_roles_active ON identity.user_roles(user_id, removed_at);
CREATE INDEX idx_user_roles_published ON identity.user_roles(published_at);
```

**Constraints:**
- ✅ UNIQUE (user_id, role_id) WHERE removed_at IS NULL: No duplicate active roles
- ✅ assigned_at ≤ published_at: Temporal ordering
- ✅ removed_at IS NULL: Active assignment tracking

**PIT Query (Get current roles for user):**
```sql
SELECT ur.user_id, r.name AS role
FROM identity.user_roles ur
JOIN identity.roles r ON ur.role_id = r.id
WHERE ur.user_id = @userId
  AND ur.published_at <= @cutoff
  AND ur.removed_at IS NULL;
```

---

### identity.user_permissions (Permission Grant)

**Purpose:** Fine-grained permission model (append-only)

```sql
CREATE TABLE identity.user_permissions (
    id BIGSERIAL PRIMARY KEY,
    
    -- Foreign Keys
    user_id UUID NOT NULL REFERENCES identity.users(id),
    
    -- Permission (domain-scoped)
    resource VARCHAR(50) NOT NULL,  -- e.g., 'users', 'portfolios', 'trades'
    action VARCHAR(20) NOT NULL,    -- 'read', 'write', 'approve', 'execute'
    
    -- Temporal
    granted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    revoked_at TIMESTAMP,  -- NULL = active, NOT NULL = revoked
    
    -- Audit
    granted_by_user_id UUID REFERENCES identity.users(id),
    correlation_id VARCHAR(36) NOT NULL,
    
    -- Constraints
    CONSTRAINT valid_resource CHECK (resource IN ('users', 'portfolios', 'trades', 'models', 'signals')),
    CONSTRAINT valid_action CHECK (action IN ('read', 'write', 'approve', 'execute')),
    CONSTRAINT unique_active_permission UNIQUE (user_id, resource, action) WHERE revoked_at IS NULL
);

CREATE INDEX idx_permissions_user ON identity.user_permissions(user_id);
CREATE INDEX idx_permissions_resource ON identity.user_permissions(resource, action);
```

---

## Data Integrity Rules

### Rule 1: Email Immutability
**Constraint:** email CANNOT be updated after creation  
**Verification:**
```sql
-- Test: Email update should fail
UPDATE identity.users SET email = 'newemail@example.com' 
WHERE id = @userId;
-- Expected: CONSTRAINT VIOLATION (or trigger prevents update)
```

### Rule 2: Password Hash Never Logged
**Constraint:** password_hash column exists but NEVER appears in SELECT without WHERE  
**Verification:**
```sql
-- Bad (never do this):
SELECT * FROM identity.users;  -- ❌ Exposes password_hash

-- Good (always explicit):
SELECT id, email, status FROM identity.users;  -- ✅ No password
```

### Rule 3: PIT (Point-in-Time) Queries Must Include Cutoff
**Constraint:** All reads include `WHERE published_at <= @cutoff`  
**Verification:**
```sql
-- Correct:
SELECT * FROM identity.users WHERE published_at <= @cutoff AND status = 'active';

-- Wrong (time-machine unsafe):
SELECT * FROM identity.users WHERE status = 'active';  -- ❌ No cutoff
```

### Rule 4: No Direct Email Mutations
**Constraint:** Email cannot be part of UPDATE statement  
**Verification (trigger):**
```sql
CREATE TRIGGER prevent_email_update
BEFORE UPDATE ON identity.users
FOR EACH ROW
WHEN (OLD.email IS DISTINCT FROM NEW.email)
EXECUTE FUNCTION raise_immutability_error('email');
```

### Rule 5: Role Removal via Soft Delete
**Constraint:** Set removed_at timestamp instead of DELETE  
**Verification:**
```sql
-- Correct:
UPDATE identity.user_roles SET removed_at = CURRENT_TIMESTAMP 
WHERE user_id = @userId AND role_id = @roleId;

-- Wrong (no DELETE):
DELETE FROM identity.user_roles WHERE user_id = @userId;  -- ❌ Banned
```

---

## Event Contracts (CDC)

### UserCreated Event

```json
{
  "eventId": "UUID",
  "eventType": "UserCreated",
  "userId": "UUID",
  "email": "user@example.com",
  "roles": ["Admin", "Analyst"],
  "createdAt": "2026-08-04T12:00:00Z",
  "correlationId": "req-001"
}
```

**When:** INSERT into identity.users  
**Consumer:** ApprovalQueue (if user requires approval)

### RoleAssigned Event

```json
{
  "eventId": "UUID",
  "eventType": "RoleAssigned",
  "userId": "UUID",
  "roleName": "Analyst",
  "assignedAt": "2026-08-04T12:00:00Z",
  "correlationId": "req-001"
}
```

**When:** INSERT into identity.user_roles with removed_at IS NULL  
**Consumer:** PermissionCache (invalidate user's permission set)

### RoleRevoked Event

```json
{
  "eventId": "UUID",
  "eventType": "RoleRevoked",
  "userId": "UUID",
  "roleName": "Analyst",
  "revokedAt": "2026-08-04T12:00:00Z",
  "correlationId": "req-001"
}
```

**When:** UPDATE identity.user_roles SET removed_at = now()  
**Consumer:** PermissionCache (invalidate user's permission set)

---

## Idempotency & Replay Safety

### Create User Idempotency

**Input:** IdempotencyKey = `create-user-alice-20260804`  
**First Run:**
```sql
INSERT INTO identity.users (email, password_hash, correlation_id)
VALUES ('alice@example.com', 'bcrypt(...)', 'req-001')
RETURNING id;
-- Result: UUID = 12345678-1234-1234-1234-123456789012
```

**Replay (same IdempotencyKey):**
```sql
-- Check if already created
SELECT id FROM identity.users WHERE email = 'alice@example.com';
-- Result: 12345678-1234-1234-1234-123456789012 (same)
-- Action: Return existing record (no duplicate INSERT)
```

### Assign Role Idempotency

**Input:** IdempotencyKey = `assign-alice-analyst-20260804`  
**First Run:**
```sql
INSERT INTO identity.user_roles (user_id, role_id, assigned_by_user_id)
VALUES (uuid-alice, 2, admin-user-id)
RETURNING id;
-- Result: ID = 1001
```

**Replay:**
```sql
-- Check if already assigned
SELECT id FROM identity.user_roles 
WHERE user_id = uuid-alice AND role_id = 2 AND removed_at IS NULL;
-- Result: 1001 (same)
-- Action: Return existing record (no duplicate)
```

---

## Acceptance Criteria Checklist

- [ ] All tables created with 3NF normalization
- [ ] PIT queries tested (published_at ≤ cutoff)
- [ ] Append-only verified (no direct UPDATE on business keys)
- [ ] Immutability enforced (email, roles)
- [ ] Soft-delete working (removed_at pattern)
- [ ] Idempotency verified (replay tests passing)
- [ ] CDC events defined (UserCreated, RoleAssigned, RoleRevoked)
- [ ] Indexes created for performance
- [ ] Constraints enforced (CHECK, UNIQUE, FK)

---

## Sign-Off

| Role | Approval | Date |
|------|----------|------|
| Data Architect | ✅ Draft | 2026-08-04 |
| DBA | ⏳ Review | TBD |
| Security | ⏳ Review | TBD |

---

**Status:** 📋 **READY FOR DOMAIN TESTS & BE IMPLEMENTATION**

Next: DomainPolicyTests (identity rules validation)
