Phase 1 Step 6: License Seat Management & Session Tracking - Design Document
Status: Design Complete → Implementation in Progress Date: December 1, 2025 Author: AI-Assisted Development Version: 1.0
Executive Summary
Implement atomic license seat counting and session tracking to enforce concurrent usage limits. This system ensures:
- Only authorized users with valid licenses can acquire seats
- Seat limits are enforced atomically (no race conditions)
- Sessions expire automatically after 6 minutes of inactivity
- Users can gracefully acquire, maintain, and release seats
Critical Path: This is the core licensing enforcement system - required for first paying customer.
Business Requirements
Use Cases
UC-1: User Acquires License Seat
Given: User has valid license key with available seats
When: User requests seat acquisition
Then: Seat is allocated atomically, session created with 6-minute TTL
UC-2: User Maintains Session (Heartbeat)
Given: User has active session
When: User sends heartbeat (every 3-5 minutes)
Then: Session TTL is refreshed to 6 minutes
UC-3: User Releases Seat
Given: User has active session
When: User releases seat (graceful shutdown)
Then: Seat is deallocated, session marked as ended
UC-4: Session Expires (Automatic)
Given: User has active session
When: 6 minutes pass without heartbeat
Then: Redis key expires, seat is automatically released
UC-5: All Seats Taken
Given: All license seats are in use
When: Another user requests seat acquisition
Then: Request is denied with 403 Forbidden (retry later)
Technical Architecture
Components Overview
┌─────────────────────────────────────────────────────────────────┐
│ Client (CODITECT App) │
└────────────────┬────────────────────────────────────────────────┘
│
│ REST API
▼
┌─────────────────────────────────────────────────────────────────┐
│ Django REST Framework │
│ ┌────────────────┐ ┌────────────────┐ ┌──────────────────┐ │
│ │ /acquire │ │ /heartbeat │ │ /release │ │
│ │ (Create) │ │ (Update) │ │ (Delete) │ │
│ └────────────────┘ └────────────────┘ └──────────────────┘ │
└──────┬──────────────────────┬──────────────────────┬───────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Redis (Atomic) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Lua Script: seat_counter.lua │ │
│ │ - INCR if < max_seats │ │
│ │ - DECR on release │ │
│ │ - SET TTL = 360 seconds (6 minutes) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ Keys: │
│ - license:{license_id}:seats_used → INT (current count) │
│ - license:{license_id}:session:{session_id} → HASH (metadata) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL (Audit Trail) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ licenses_licensesession │ │
│ │ - id (UUID) │ │
│ │ - license_id (FK) │ │
│ │ - started_at, last_heartbeat_at, ended_at │ │
│ │ - machine_id, ip_address, user_agent │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Data Flow
Acquire Seat:
1. Client → POST /api/v1/licenses/acquire
Body: {
"license_key": "CODITECT-2025-A7B3-X9K2",
"machine_id": "mac-12345",
"ip_address": "192.168.1.1",
"user_agent": "CODITECT/1.0"
}
2. Django validates license (active, not expired)
3. Redis Lua script executes atomically:
- Check: seats_used < max_concurrent_seats
- If yes: INCR seats_used, SET TTL 360, return success
- If no: return error (all seats taken)
4. PostgreSQL creates LicenseSession record (audit)
5. Response → 201 Created
Body: {
"session_id": "uuid",
"expires_at": "2025-12-01T12:06:00Z",
"seats_remaining": 2
}
Heartbeat (Keep-Alive):
1. Client → PATCH /api/v1/licenses/sessions/{session_id}/heartbeat
Headers: Authorization: Bearer {jwt_token}
2. Django validates session exists and is active
3. Redis refreshes TTL:
- SET license:{id}:session:{session_id} EXPIRE 360
4. PostgreSQL updates last_heartbeat_at
5. Response → 200 OK
Body: {
"session_id": "uuid",
"expires_at": "2025-12-01T12:11:00Z" (updated)
}
Release Seat:
1. Client → DELETE /api/v1/licenses/sessions/{session_id}
Headers: Authorization: Bearer {jwt_token}
2. Django validates session ownership
3. Redis decrements seat count:
- DECR license:{id}:seats_used
- DEL license:{id}:session:{session_id}
4. PostgreSQL marks ended_at timestamp
5. Response → 204 No Content
Database Schema
LicenseSession Model
class LicenseSession(TenantModel):
"""Active license session tracking with TTL management."""
tenant_id = 'license__organization_id'
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
license = models.ForeignKey(
'License',
on_delete=models.CASCADE,
related_name='sessions'
)
# Session lifecycle
started_at = models.DateTimeField(auto_now_add=True)
last_heartbeat_at = models.DateTimeField(auto_now_add=True)
ended_at = models.DateTimeField(null=True, blank=True)
# Client identification
machine_id = models.CharField(max_length=255)
ip_address = models.GenericIPAddressField()
user_agent = models.CharField(max_length=500)
# Session metadata
metadata = models.JSONField(default=dict)
class Meta:
db_table = 'licenses_licensesession'
ordering = ['-started_at']
indexes = [
models.Index(fields=['license', 'ended_at']),
models.Index(fields=['license', 'machine_id']),
models.Index(fields=['last_heartbeat_at']),
]
constraints = [
models.UniqueConstraint(
fields=['license', 'machine_id'],
condition=models.Q(ended_at__isnull=True),
name='unique_active_session_per_machine'
)
]
def is_active(self):
"""Check if session is active (no end time, recent heartbeat)."""
if self.ended_at is not None:
return False
threshold = timezone.now() - timedelta(minutes=6)
return self.last_heartbeat_at > threshold
@property
def expires_at(self):
"""Calculate expiry time (6 minutes from last heartbeat)."""
return self.last_heartbeat_at + timedelta(minutes=6)
Migration
-- Migration 0006_phase1_step6_license_sessions.py
CREATE TABLE licenses_licensesession (
id UUID PRIMARY KEY,
license_id UUID NOT NULL REFERENCES licenses_license(id) ON DELETE CASCADE,
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_heartbeat_at TIMESTAMP WITH TIME ZONE NOT NULL,
ended_at TIMESTAMP WITH TIME ZONE,
machine_id VARCHAR(255) NOT NULL,
ip_address INET NOT NULL,
user_agent VARCHAR(500) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for performance
CREATE INDEX idx_session_license_ended ON licenses_licensesession(license_id, ended_at);
CREATE INDEX idx_session_license_machine ON licenses_licensesession(license_id, machine_id);
CREATE INDEX idx_session_heartbeat ON licenses_licensesession(last_heartbeat_at);
-- Unique constraint: one active session per machine per license
CREATE UNIQUE INDEX idx_unique_active_session
ON licenses_licensesession(license_id, machine_id)
WHERE ended_at IS NULL;
Redis Lua Scripts
Atomic Seat Counter (seat_counter.lua)
-- acquire_seat.lua
-- Atomically increment seat count if under limit
-- Args: license_key, session_id, max_seats, ttl_seconds
-- Returns: {status: 'success'|'full', seats_used: int, seats_remaining: int}
local license_key = KEYS[1]
local session_key = KEYS[2]
local max_seats = tonumber(ARGV[1])
local ttl_seconds = tonumber(ARGV[2])
local session_data = ARGV[3]
-- Get current seat count
local seats_used = tonumber(redis.call('GET', license_key) or '0')
-- Check if seats available
if seats_used >= max_seats then
return {
status = 'full',
seats_used = seats_used,
seats_remaining = 0
}
end
-- Increment seat count
seats_used = redis.call('INCR', license_key)
-- Set expiry on seat counter (TTL)
redis.call('EXPIRE', license_key, ttl_seconds)
-- Create session key with metadata
redis.call('HSET', session_key,
'started_at', redis.call('TIME')[1],
'last_heartbeat_at', redis.call('TIME')[1],
'data', session_data
)
redis.call('EXPIRE', session_key, ttl_seconds)
return {
status = 'success',
seats_used = seats_used,
seats_remaining = max_seats - seats_used
}
Release Seat Script (release_seat.lua)
-- release_seat.lua
-- Atomically decrement seat count
-- Args: license_key, session_key
-- Returns: {status: 'success', seats_used: int}
local license_key = KEYS[1]
local session_key = KEYS[2]
-- Check if session exists
if redis.call('EXISTS', session_key) == 0 then
return {status = 'not_found'}
end
-- Decrement seat count (minimum 0)
local seats_used = redis.call('DECR', license_key)
if seats_used < 0 then
redis.call('SET', license_key, 0)
seats_used = 0
end
-- Delete session key
redis.call('DEL', session_key)
return {
status = 'success',
seats_used = seats_used
}
Refresh Heartbeat Script (refresh_heartbeat.lua)
-- refresh_heartbeat.lua
-- Refresh session TTL
-- Args: license_key, session_key, ttl_seconds
-- Returns: {status: 'success'|'expired'}
local license_key = KEYS[1]
local session_key = KEYS[2]
local ttl_seconds = tonumber(ARGV[1])
-- Check if session exists
if redis.call('EXISTS', session_key) == 0 then
return {status = 'expired'}
end
-- Update heartbeat timestamp
redis.call('HSET', session_key, 'last_heartbeat_at', redis.call('TIME')[1])
-- Refresh TTL on both keys
redis.call('EXPIRE', license_key, ttl_seconds)
redis.call('EXPIRE', session_key, ttl_seconds)
return {status = 'success'}
API Endpoints
1. POST /api/v1/licenses/acquire
Purpose: Acquire a license seat for the current user/machine.
Request:
POST /api/v1/licenses/acquire
Content-Type: application/json
Authorization: Bearer {jwt_token}
{
"license_key": "CODITECT-2025-A7B3-X9K2",
"machine_id": "mac-12345-67890",
"user_agent": "CODITECT/1.0.0 (Windows 10)",
"metadata": {
"app_version": "1.0.0",
"os": "Windows 10"
}
}
Response (Success):
HTTP/1.1 201 Created
Content-Type: application/json
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"license_key": "CODITECT-2025-A7B3-X9K2",
"started_at": "2025-12-01T12:00:00Z",
"expires_at": "2025-12-01T12:06:00Z",
"seats_used": 1,
"seats_remaining": 2,
"heartbeat_interval_seconds": 180
}
Response (All Seats Taken):
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "All license seats are currently in use",
"seats_available": 0,
"seats_total": 3,
"retry_after_seconds": 60
}
Response (Invalid License):
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": "License not found or inactive",
"detail": "The provided license key is invalid or has been deactivated"
}
Response (License Expired):
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"error": "License has expired",
"expired_at": "2025-11-01T00:00:00Z",
"contact_support": "support@coditect.ai"
}
2. PATCH /api/v1/licenses/sessions/{session_id}/heartbeat
Purpose: Refresh session TTL to prevent expiration.
Request:
PATCH /api/v1/licenses/sessions/550e8400-e29b-41d4-a716-446655440000/heartbeat
Authorization: Bearer {jwt_token}
Response (Success):
HTTP/1.1 200 OK
Content-Type: application/json
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"last_heartbeat_at": "2025-12-01T12:03:00Z",
"expires_at": "2025-12-01T12:09:00Z",
"status": "active"
}
Response (Session Expired):
HTTP/1.1 410 Gone
Content-Type: application/json
{
"error": "Session has expired",
"detail": "No heartbeat received within 6 minutes. Please acquire a new seat.",
"last_heartbeat_at": "2025-12-01T11:54:00Z"
}
3. DELETE /api/v1/licenses/sessions/{session_id}
Purpose: Gracefully release license seat.
Request:
DELETE /api/v1/licenses/sessions/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer {jwt_token}
Response (Success):
HTTP/1.1 204 No Content
Response (Session Not Found):
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": "Session not found",
"detail": "The specified session does not exist or has already been released"
}
Security Considerations
1. Authentication & Authorization
- JWT Required: All endpoints require valid JWT token
- Organization Scoping: Users can only manage sessions for their organization's licenses
- Machine Binding: Sessions are tied to specific machine IDs
2. Rate Limiting
# Throttle classes
class AcquireRateThrottle(AnonRateThrottle):
rate = '10/minute' # Prevent rapid seat acquisition attempts
class HeartbeatRateThrottle(UserRateThrottle):
rate = '20/minute' # Allow frequent heartbeats (every 3-5 min recommended)
3. Audit Logging
All seat operations logged to AuditLog model:
LICENSE_SEAT_ACQUIRED- Seat allocationLICENSE_SEAT_RELEASED- Seat releaseLICENSE_SEAT_EXPIRED- Automatic expirationLICENSE_SEAT_DENIED- Acquisition denied (all seats taken)
4. IP Address Validation
def get_client_ip(request):
"""Extract client IP from request headers."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
Error Handling
Retry Logic (Client-Side Recommendation)
# Client pseudo-code
def acquire_seat_with_retry(license_key, max_retries=3):
for attempt in range(max_retries):
response = api.post('/licenses/acquire', {
'license_key': license_key,
'machine_id': get_machine_id()
})
if response.status == 201:
return response.json()
if response.status == 403:
# All seats taken
retry_after = response.json().get('retry_after_seconds', 60)
time.sleep(retry_after)
continue
# Other errors (4xx, 5xx)
raise Exception(response.json()['error'])
raise Exception("Failed to acquire seat after retries")
Graceful Degradation
# If Redis is unavailable, fall back to PostgreSQL-only mode
# (slower, but maintains service availability)
class RedisUnavailableException(Exception):
pass
def acquire_seat(license, machine_id):
try:
# Try Redis first (fast, atomic)
return acquire_seat_redis(license, machine_id)
except RedisUnavailableException:
logger.warning("Redis unavailable, using PostgreSQL fallback")
return acquire_seat_postgres(license, machine_id)
Performance Considerations
1. Redis Key Expiration
- TTL: 360 seconds (6 minutes)
- Automatic Cleanup: Redis evicts expired keys automatically
- Memory Usage: Minimal (simple counters + session hashes)
2. Database Queries
# Optimized query with select_related
session = LicenseSession.objects.select_related(
'license',
'license__organization'
).get(id=session_id)
# Index usage
# - license_id + ended_at (active sessions query)
# - last_heartbeat_at (expiration cleanup)
3. Lua Script Atomicity
- Single Round-Trip: All Redis operations in one atomic script call
- No Race Conditions: INCR/DECR are atomic by design
- Consistent State: TTL applied atomically with counter update
Testing Strategy
Unit Tests
class TestAcquireSeat:
def test_acquire_seat_success(self):
"""Test successful seat acquisition."""
def test_acquire_seat_all_taken(self):
"""Test rejection when all seats in use."""
def test_acquire_seat_expired_license(self):
"""Test rejection for expired license."""
def test_acquire_seat_inactive_license(self):
"""Test rejection for inactive license."""
class TestHeartbeat:
def test_heartbeat_success(self):
"""Test successful heartbeat refresh."""
def test_heartbeat_expired_session(self):
"""Test heartbeat on expired session."""
def test_heartbeat_unauthorized(self):
"""Test heartbeat with wrong user."""
class TestReleaseSeat:
def test_release_seat_success(self):
"""Test successful seat release."""
def test_release_seat_not_found(self):
"""Test release of non-existent session."""
Integration Tests
class TestLicenseSessionFlow:
def test_complete_session_lifecycle(self):
"""Test acquire → heartbeat → release flow."""
# 1. Acquire seat
# 2. Send heartbeat
# 3. Release seat
# 4. Verify seat count decremented
def test_automatic_expiration(self):
"""Test that session expires after 6 minutes."""
# 1. Acquire seat
# 2. Wait 6+ minutes (mock time)
# 3. Verify session expired
# 4. Verify seat count decremented
Load Tests
class TestConcurrency:
def test_concurrent_acquisitions(self):
"""Test race condition handling with concurrent requests."""
# Spawn 10 threads trying to acquire 3 seats
# Verify only 3 succeed, 7 fail with 403
def test_heartbeat_storm(self):
"""Test system under heavy heartbeat load."""
# 100 active sessions, all sending heartbeats
# Verify all heartbeats processed successfully
Deployment Checklist
- Redis connection configured (REDIS_URL in .env)
- Redis Lua scripts deployed to
licenses/redis_scripts.py - Database migration applied (0006_phase1_step6_license_sessions.py)
- API endpoints registered in
api/v1/urls.py - Monitoring alerts configured (seat usage threshold warnings)
- Load testing completed (100+ concurrent sessions)
- Documentation updated (api-quick-reference.md)
Monitoring & Alerts
Key Metrics
# Prometheus metrics
license_seats_used = Gauge('license_seats_used', 'Current seats in use', ['license_id'])
license_seats_total = Gauge('license_seats_total', 'Total available seats', ['license_id'])
license_acquire_requests = Counter('license_acquire_requests_total', 'Total acquire requests')
license_acquire_failures = Counter('license_acquire_failures_total', 'Failed acquire requests', ['reason'])
session_heartbeat_latency = Histogram('session_heartbeat_latency_seconds', 'Heartbeat processing time')
Alerting Rules
# Prometheus alert rules
- alert: LicenseSeatsNearlyFull
expr: (license_seats_used / license_seats_total) > 0.9
for: 5m
labels:
severity: warning
annotations:
summary: "License seats 90% utilized"
- alert: HighAcquireFailureRate
expr: rate(license_acquire_failures_total[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "High rate of failed seat acquisitions"
Success Criteria
Step 6 is complete when:
- ✅ LicenseSession model created and migrated
- ✅ Redis Lua scripts implemented and tested
- ✅ All 3 API endpoints functional (/acquire, /heartbeat, /release)
- ✅ Atomic seat counting verified (no race conditions)
- ✅ TTL-based expiration working (6-minute timeout)
- ✅ Unit tests passing (>80% coverage)
- ✅ Integration tests passing (complete lifecycle)
- ✅ Load tests passing (100+ concurrent sessions)
- ✅ API documentation updated
Estimated Implementation Time: 4-6 hours
Next Steps (Step 7 Preview)
After Step 6 completion, Step 7 will implement client-side heartbeat integration:
- Background thread for automatic heartbeats (every 3-5 minutes)
- Automatic reconnection on network failure
- Graceful degradation when offline
- Session recovery after app restart
Document Status: Design Complete ✅ Ready for Implementation: Yes Dependencies: Redis, PostgreSQL, Django REST Framework Estimated LOC: ~1,200 lines (views, serializers, Lua scripts, tests)