diff --git a/docs/contracts/architecture/VS-01_SLICE_SPEC.md b/docs/contracts/architecture/VS-01_SLICE_SPEC.md
new file mode 100644
index 00000000..c60547eb
--- /dev/null
+++ b/docs/contracts/architecture/VS-01_SLICE_SPEC.md
@@ -0,0 +1,164 @@
+# VS-01: Manage Identity and Roles - Vertical Slice Specification
+
+**Slice ID:** VS-01
+**Batch:** 1 (no dependencies)
+**Status:** 📋 SPECIFICATION
+**Created:** 2026-08-04
+
+---
+
+## Executive Summary
+
+Establish centralized **Identity and Role Management (IAM)** system for K-ArtSell platform.
+
+**User Goal:** Administrators can manage user accounts, roles, and permissions from a single dashboard without manual database operations.
+
+**Non-Goal:**
+- SSO/LDAP integration (Phase 3)
+- MFA implementation (Phase 3)
+- Audit trail (separate feature)
+- Password reset workflow (Phase 3)
+
+---
+
+## Acceptance Criteria
+
+### 1. User Management ✅
+
+- [ ] **Create User:** Endpoint creates new user record with UUID, email, hashed password, roles
+- [ ] **Read Users:** Paginated list, filterable by role/status
+- [ ] **Update User:** Change email, roles (no password update here)
+- [ ] **Soft Delete:** Mark user as inactive (no hard delete)
+- [ ] **Validation:** Email unique per environment, password ≥12 chars
+
+### 2. Role & Permission Model ✅
+
+- [ ] **Predefined Roles:** Admin, Analyst, Trader, Viewer (immutable)
+- [ ] **Permissions:** Read, Write, Approve, Execute (scoped to domain)
+- [ ] **User-Role Mapping:** Many-to-many with assigned_at timestamp
+- [ ] **Permission Enforcement:** Checked on every endpoint (via PermissionGuard)
+
+### 3. Data Integrity ✅
+
+- [ ] **PIT Compliance:** created_at (never future), updated_at, published_at (for CDC)
+- [ ] **Immutable:** user_id, email_hash cannot change post-creation
+- [ ] **Revision Tracking:** Each role change creates new record (append-only)
+- [ ] **Schema-Qualified:** All queries use `identity.users`, `identity.roles`
+
+### 4. API Contracts ✅
+
+**Endpoint: POST /api/users**
+```
+Request: { email: string, password: string, roles: ["Admin", "Analyst"] }
+Response: 201 Created { userId: UUID, email: string, roles: [string] }
+Errors: 400 (invalid), 409 (exists), 422 (validation)
+Idempotency: IdempotencyKey header
+```
+
+**Endpoint: GET /api/users?page=1&limit=20&role=Admin**
+```
+Response: 200 { items: [User], total: int, page: int, limit: int }
+Errors: 401, 403 (insufficient permissions)
+```
+
+**Endpoint: PATCH /api/users/:id**
+```
+Request: { roles: ["Analyst", "Viewer"], status: "active" }
+Response: 200 { userId: UUID, roles: [string], updated_at: timestamp }
+```
+
+### 5. UI/UX Acceptance Criteria ✅
+
+- [ ] **User List Page:** Table with columns (Email, Roles, Status, Actions)
+- [ ] **Create Dialog:** Form with email + password + role multi-select
+- [ ] **Edit Dialog:** Change roles inline
+- [ ] **Delete Dialog:** Confirm soft-delete with warning
+- [ ] **Accessibility:** ARIA labels, keyboard nav, error messages
+
+### 6. Security Acceptance Criteria ✅
+
+- [ ] **Password Hashing:** bcrypt or argon2, never plaintext
+- [ ] **Auth Check:** Every endpoint requires role (no anonymous)
+- [ ] **Authorization:** Only Admin can modify users
+- [ ] **Audit Logging:** User changes logged with correlationId
+- [ ] **No PII in Logs:** Email, password NEVER logged
+
+---
+
+## Failure Modes & Recovery
+
+### Scenario 1: Duplicate Email
+
+**Trigger:** POST /api/users with existing email
+**Expected:** 409 Conflict { error: "Email already exists" }
+**Recovery:** User retries with different email
+
+### Scenario 2: Invalid Role
+
+**Trigger:** POST /api/users with role="SuperAdmin" (not in predefined list)
+**Expected:** 422 Unprocessable { error: "Invalid role: SuperAdmin" }
+**Recovery:** User selects from dropdown of valid roles
+
+### Scenario 3: Concurrent Role Update
+
+**Trigger:** 2 admins modify same user's roles simultaneously
+**Expected:** Last-write-wins (UPDATE WHERE version = @version, increment version)
+**Recovery:** Second request gets 409 Conflict, user retries with fresh data
+
+---
+
+## Success Metrics
+
+| Metric | Target | Verification |
+|--------|--------|--------------|
+| Create latency | <200ms | Load test |
+| List latency | <500ms (1000 users) | Stress test |
+| Auth check latency | <50ms | Endpoint latency trace |
+| Test coverage | ≥95% | Code coverage report |
+| Uptime | ≥99.9% | Monitoring dashboard |
+
+---
+
+## Dependencies
+
+### Inbound (Block VS-01)
+
+- ✅ **VS-00:** Platform foundation (complete)
+- ✅ **Authentication:** DevelopmentHeader + FailClosed (Phase 1)
+
+### Outbound (Unblock)
+
+- 🔄 **VS-07:** ManageClientIPS (depends on VS-01 for User/Role APIs)
+- 🔄 **VS-02~08:** All slices use VS-01's permission model
+
+---
+
+## Component Breakdown (7 items per slice)
+
+| Component | Owner | Duration | Status |
+|-----------|-------|----------|--------|
+| **GOV** (this doc) | Architect | 1-2 hrs | 📋 |
+| **DATA** | Data Architect | 2-3 hrs | ⏳ Ready |
+| **DOMAIN** | Quant Lead | 2-3 hrs | ⏳ Ready |
+| **BE** | BE Lead | 3-4 hrs | ⏳ Ready |
+| **ASYNC** | SRE | 2-3 hrs | ⏳ Ready |
+| **FE** | FE Architect | 3-4 hrs | ⏳ Ready |
+| **TESTOPS** | QA Lead | 2-3 hrs | ⏳ Ready |
+
+**Total Duration:** ~18-22 hours (wall-clock ~3 days)
+
+---
+
+## Sign-Off
+
+| Role | Name | Status | Date |
+|------|------|--------|------|
+| Product Owner | User | ⏳ Approval | TBD |
+| Architect | Claude Code | ✅ Draft | 2026-08-04 |
+| Security | Team | ⏳ Review | TBD |
+
+---
+
+**Status:** 📋 **READY FOR DATA/DOMAIN/BE COMPONENTS**
+
+Next: VS-01_DATA_CONTRACT.md
diff --git a/docs/contracts/data/VS-01_DATA_CONTRACT.md b/docs/contracts/data/VS-01_DATA_CONTRACT.md
new file mode 100644
index 00000000..7040e541
--- /dev/null
+++ b/docs/contracts/data/VS-01_DATA_CONTRACT.md
@@ -0,0 +1,374 @@
+# 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)
diff --git a/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs b/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs
new file mode 100644
index 00000000..d85b0f64
--- /dev/null
+++ b/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs
@@ -0,0 +1,448 @@
+using Xunit;
+
+namespace KArtSell.ModelOperations.UnitTests;
+
+///
+/// VS-01: Identity and Roles - Domain Policy Tests
+/// Pure logic validation (no database, no infrastructure)
+/// Covers: Role hierarchy, permission grant, email validation
+///
+public sealed class VS01_IdentityPolicyTests
+{
+ // ============ Email Validation Policy ============
+
+ [Theory]
+ [InlineData("alice@example.com")]
+ [InlineData("bob.smith@company.co.uk")]
+ [InlineData("user+tag@domain.org")]
+ public void ValidateEmail_ValidFormats_AreAccepted(string email)
+ {
+ // Arrange & Act
+ var isValid = EmailPolicy.IsValidFormat(email);
+
+ // Assert
+ Assert.True(isValid, $"Email '{email}' should be valid");
+ }
+
+ [Theory]
+ [InlineData("invalid@")]
+ [InlineData("@domain.com")]
+ [InlineData("alice@.com")]
+ [InlineData("alice@@example.com")]
+ [InlineData("alice.example.com")]
+ public void ValidateEmail_InvalidFormats_AreRejected(string email)
+ {
+ // Arrange & Act
+ var isValid = EmailPolicy.IsValidFormat(email);
+
+ // Assert
+ Assert.False(isValid, $"Email '{email}' should be invalid");
+ }
+
+ [Fact]
+ public void ValidateEmail_CaseSensitivity_IsNormalized()
+ {
+ // Policy: emails are case-insensitive, stored lowercase
+ // Arrange
+ var upper = "Alice@Example.COM";
+ var lower = EmailPolicy.Normalize(upper);
+
+ // Act & Assert
+ Assert.Equal("alice@example.com", lower);
+ }
+
+ // ============ Password Validation Policy ============
+
+ [Theory]
+ [InlineData("TooShort", false)] // 8 chars < 12
+ [InlineData("ValidPassword123", true)] // 16 chars ≥ 12
+ [InlineData("123456789012", true)] // Exactly 12 chars
+ public void ValidatePassword_LengthRequirement_Enforced(string password, bool expected)
+ {
+ // Arrange & Act
+ var isValid = PasswordPolicy.IsValidLength(password);
+
+ // Assert
+ Assert.Equal(expected, isValid);
+ }
+
+ [Fact]
+ public void ValidatePassword_EmptyPassword_Rejected()
+ {
+ // Arrange
+ var password = "";
+
+ // Act
+ var isValid = PasswordPolicy.IsValidLength(password);
+
+ // Assert
+ Assert.False(isValid);
+ }
+
+ // ============ Role Management Policy ============
+
+ [Fact]
+ public void AssignRole_NewUserGetRole_IsSuccessful()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var role = "Analyst";
+ var user = new UserAggregate(userId, "alice@example.com");
+
+ // Act
+ user.AssignRole(role);
+
+ // Assert
+ Assert.Contains(role, user.Roles);
+ }
+
+ [Fact]
+ public void AssignRole_DuplicateRole_IsIdempotent()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var role = "Analyst";
+ var user = new UserAggregate(userId, "alice@example.com");
+
+ // Act
+ user.AssignRole(role);
+ var countAfterFirst = user.Roles.Count;
+
+ user.AssignRole(role); // Same role again
+ var countAfterSecond = user.Roles.Count;
+
+ // Assert
+ Assert.Equal(countAfterFirst, countAfterSecond,
+ "Duplicate role assignment should not increase count");
+ }
+
+ [Fact]
+ public void RevokeRole_ActiveRole_IsRemoved()
+ {
+ // Arrange
+ var userId = Guid.NewGuid();
+ var user = new UserAggregate(userId, "alice@example.com");
+ user.AssignRole("Analyst");
+ user.AssignRole("Trader");
+
+ // Act
+ user.RevokeRole("Analyst");
+
+ // Assert
+ Assert.DoesNotContain("Analyst", user.Roles);
+ Assert.Contains("Trader", user.Roles); // Other roles unaffected
+ }
+
+ [Fact]
+ public void RevokeRole_NonExistentRole_IsIdempotent()
+ {
+ // Arrange
+ var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
+
+ // Act
+ var threw = false;
+ try
+ {
+ user.RevokeRole("NonExistentRole");
+ }
+ catch
+ {
+ threw = true;
+ }
+
+ // Assert
+ Assert.False(threw, "Revoking non-existent role should not throw");
+ }
+
+ // ============ Permission Hierarchy Policy ============
+
+ [Theory]
+ [InlineData("Admin", "read", true)]
+ [InlineData("Admin", "write", true)]
+ [InlineData("Admin", "approve", true)]
+ [InlineData("Analyst", "read", true)]
+ [InlineData("Analyst", "write", false)]
+ [InlineData("Analyst", "approve", false)]
+ [InlineData("Trader", "read", true)]
+ [InlineData("Trader", "write", true)]
+ [InlineData("Trader", "execute", true)]
+ [InlineData("Viewer", "read", true)]
+ [InlineData("Viewer", "write", false)]
+ public void PermissionHierarchy_RoleActions_AreEnforced(string role, string action, bool expected)
+ {
+ // Arrange & Act
+ var hasPermission = PermissionPolicy.CanPerform(role, action);
+
+ // Assert
+ Assert.Equal(expected, hasPermission,
+ $"Role '{role}' should {'NOT ' if !expected:string.Empty}be able to '{action}'");
+ }
+
+ [Fact]
+ public void PermissionHierarchy_MultipleRoles_AreUnioned()
+ {
+ // Policy: If user has multiple roles, they can perform ANY of the role's actions
+ // Arrange
+ var roles = new[] { "Analyst", "Trader" };
+
+ // Act
+ var canRead = roles.Any(r => PermissionPolicy.CanPerform(r, "read"));
+ var canWrite = roles.Any(r => PermissionPolicy.CanPerform(r, "write"));
+ var canApprove = roles.Any(r => PermissionPolicy.CanPerform(r, "approve"));
+
+ // Assert
+ Assert.True(canRead, "Should have read permission");
+ Assert.True(canWrite, "Should have write permission (from Trader)");
+ Assert.False(canApprove, "Should NOT have approve permission");
+ }
+
+ // ============ User Status Transitions ============
+
+ [Theory]
+ [InlineData("active", "inactive", true)]
+ [InlineData("active", "suspended", true)]
+ [InlineData("inactive", "active", true)]
+ [InlineData("inactive", "suspended", true)]
+ [InlineData("suspended", "active", false)] // Cannot reactivate from suspended
+ [InlineData("suspended", "inactive", false)] // Cannot reactivate from suspended
+ public void UserStatus_Transitions_AreValidated(string from, string to, bool valid)
+ {
+ // Arrange & Act
+ var canTransition = UserStatusPolicy.CanTransition(from, to);
+
+ // Assert
+ Assert.Equal(valid, canTransition,
+ $"Transition '{from}' → '{to}' should be {(valid ? "allowed" : "forbidden")}");
+ }
+
+ [Fact]
+ public void UserStatus_SuspendedUser_CannotLogin()
+ {
+ // Arrange
+ var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
+ user.UpdateStatus("suspended");
+
+ // Act
+ var canLogin = user.CanLogin();
+
+ // Assert
+ Assert.False(canLogin, "Suspended user should not be able to login");
+ }
+
+ // ============ Admin-Only Operations ============
+
+ [Fact]
+ public void AdminOnly_CreateUser_RequiresAdminRole()
+ {
+ // Arrange
+ var adminUser = new UserAggregate(Guid.NewGuid(), "admin@example.com");
+ adminUser.AssignRole("Admin");
+
+ var analystUser = new UserAggregate(Guid.NewGuid(), "analyst@example.com");
+ analystUser.AssignRole("Analyst");
+
+ var newUserEmail = "newuser@example.com";
+
+ // Act & Assert
+ Assert.True(AdminPolicy.CanCreateUser(adminUser),
+ "Admin should be able to create users");
+
+ Assert.False(AdminPolicy.CanCreateUser(analystUser),
+ "Non-admin should NOT be able to create users");
+ }
+
+ [Fact]
+ public void AdminOnly_ModifyRoles_RequiresAdminRole()
+ {
+ // Arrange
+ var admin = new UserAggregate(Guid.NewGuid(), "admin@example.com");
+ admin.AssignRole("Admin");
+
+ var analyst = new UserAggregate(Guid.NewGuid(), "analyst@example.com");
+ analyst.AssignRole("Analyst");
+
+ // Act & Assert
+ Assert.True(AdminPolicy.CanModifyRoles(admin),
+ "Admin should be able to modify roles");
+
+ Assert.False(AdminPolicy.CanModifyRoles(analyst),
+ "Analyst should NOT be able to modify roles");
+ }
+
+ // ============ Immutability Policy ============
+
+ [Fact]
+ public void Immutability_Email_CannotBeChanged()
+ {
+ // Arrange
+ var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
+
+ // Act
+ var canChange = user.CanChangeEmail("newemail@example.com");
+
+ // Assert
+ Assert.False(canChange, "Email should be immutable after creation");
+ }
+
+ [Fact]
+ public void Immutability_UserId_CannotBeChanged()
+ {
+ // Arrange
+ var originalId = Guid.NewGuid();
+ var user = new UserAggregate(originalId, "alice@example.com");
+
+ // Act
+ var canChange = user.CanChangeId(Guid.NewGuid());
+
+ // Assert
+ Assert.False(canChange, "User ID should be immutable");
+ }
+
+ // ============ Soft Delete Policy ============
+
+ [Fact]
+ public void SoftDelete_InactiveUser_DoesNotAppearInLists()
+ {
+ // Arrange
+ var activeUser = new UserAggregate(Guid.NewGuid(), "active@example.com");
+ var inactiveUser = new UserAggregate(Guid.NewGuid(), "inactive@example.com");
+ inactiveUser.UpdateStatus("inactive");
+
+ var users = new[] { activeUser, inactiveUser };
+
+ // Act
+ var activeCount = users.Count(u => u.CanLogin());
+
+ // Assert
+ Assert.Equal(1, activeCount, "Only active users should be counted");
+ }
+
+ // ============ Consistency Checks ============
+
+ [Fact]
+ public void Consistency_UserWithoutRoles_IsInvalid()
+ {
+ // Policy: Every user must have at least one role
+ // Arrange
+ var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
+
+ // Act
+ var isValid = user.IsValid();
+
+ // Assert
+ Assert.False(isValid, "User must have at least one role");
+ }
+
+ [Fact]
+ public void Consistency_UserWithValidRole_IsValid()
+ {
+ // Arrange
+ var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
+ user.AssignRole("Analyst");
+
+ // Act
+ var isValid = user.IsValid();
+
+ // Assert
+ Assert.True(isValid, "User with valid role should be valid");
+ }
+
+ [Fact]
+ public void Consistency_UserWithInvalidRole_IsRejected()
+ {
+ // Arrange
+ var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
+
+ // Act & Assert
+ Assert.Throws(() => user.AssignRole("InvalidRole"));
+ }
+}
+
+// ============ Helper Classes (Domain Policies) ============
+
+public static class EmailPolicy
+{
+ public static bool IsValidFormat(string email)
+ {
+ if (string.IsNullOrWhiteSpace(email)) return false;
+ return System.Text.RegularExpressions.Regex.IsMatch(
+ email,
+ @"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$");
+ }
+
+ public static string Normalize(string email) => email.ToLowerInvariant();
+}
+
+public static class PasswordPolicy
+{
+ public static bool IsValidLength(string password) => !string.IsNullOrEmpty(password) && password.Length >= 12;
+}
+
+public static class PermissionPolicy
+{
+ private static readonly Dictionary RolePermissions = new()
+ {
+ { "Admin", new[] { "read", "write", "approve", "execute" } },
+ { "Analyst", new[] { "read" } },
+ { "Trader", new[] { "read", "write", "execute" } },
+ { "Viewer", new[] { "read" } },
+ };
+
+ public static bool CanPerform(string role, string action)
+ {
+ return RolePermissions.TryGetValue(role, out var permissions) &&
+ permissions.Contains(action);
+ }
+}
+
+public static class UserStatusPolicy
+{
+ public static bool CanTransition(string from, string to)
+ {
+ // Suspended users cannot be reactivated
+ if (from == "suspended") return false;
+ return from != to;
+ }
+}
+
+public static class AdminPolicy
+{
+ public static bool CanCreateUser(UserAggregate user) => user.Roles.Contains("Admin");
+ public static bool CanModifyRoles(UserAggregate user) => user.Roles.Contains("Admin");
+}
+
+public class UserAggregate
+{
+ public Guid Id { get; }
+ public string Email { get; }
+ public List Roles { get; } = new();
+ public string Status { get; set; } = "active";
+
+ public UserAggregate(Guid id, string email)
+ {
+ Id = id;
+ Email = EmailPolicy.Normalize(email);
+ }
+
+ public void AssignRole(string role)
+ {
+ if (!new[] { "Admin", "Analyst", "Trader", "Viewer" }.Contains(role))
+ throw new ArgumentException($"Invalid role: {role}");
+
+ if (!Roles.Contains(role))
+ Roles.Add(role);
+ }
+
+ public void RevokeRole(string role)
+ {
+ Roles.Remove(role);
+ }
+
+ public bool CanLogin() => Status == "active";
+ public bool CanChangeEmail(string newEmail) => false; // Always immutable
+ public bool CanChangeId(Guid newId) => false; // Always immutable
+ public void UpdateStatus(string newStatus) => Status = newStatus;
+
+ public bool IsValid() => Roles.Count > 0 && Roles.All(r =>
+ new[] { "Admin", "Analyst", "Trader", "Viewer" }.Contains(r));
+}