Skip to main content

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 allocation
  • LICENSE_SEAT_RELEASED - Seat release
  • LICENSE_SEAT_EXPIRED - Automatic expiration
  • LICENSE_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:

  1. ✅ LicenseSession model created and migrated
  2. ✅ Redis Lua scripts implemented and tested
  3. ✅ All 3 API endpoints functional (/acquire, /heartbeat, /release)
  4. ✅ Atomic seat counting verified (no race conditions)
  5. ✅ TTL-based expiration working (6-minute timeout)
  6. ✅ Unit tests passing (>80% coverage)
  7. ✅ Integration tests passing (complete lifecycle)
  8. ✅ Load tests passing (100+ concurrent sessions)
  9. ✅ 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)