ADR-001: Floating Licenses vs. Node-Locked Licenses
Status: Accepted Date: 2025-11-30 Deciders: Architecture Team, Product Team Tags: licensing, concurrency, user-experience
Context
CODITECT-CORE requires a licensing model that balances business needs (revenue protection, usage tracking) with developer experience (flexibility, minimal friction). We must decide between:
- Floating/Concurrent Licenses - Shared pool of seats, first-come-first-served
- Node-Locked Licenses - License tied to specific machine/hardware
Business Requirements
- Fair Pricing: Developers shouldn't pay for idle licenses
- Team Flexibility: Small teams share licenses across developers
- Enterprise Scale: Large organizations need efficient license utilization
- Revenue Protection: Prevent unlimited sharing while allowing reasonable usage
Technical Constraints
- CODITECT-CORE installed as git submodule with symlink chains
- Developers work across multiple projects (1 developer, 10 projects = 10 symlinks)
- Offline development required (flights, coffee shops, air-gapped environments)
- Claude Code sessions can be long-lived (8+ hours of active development)
User Experience Goals
- Minimal Friction: License checks shouldn't interrupt workflow
- Transparent Limits: Clear feedback when seats exhausted
- Graceful Degradation: Work offline when network unavailable
- No License Waste: Idle licenses automatically released
Decision
We will implement Floating/Concurrent Licenses with Redis-based atomic seat management.
License Pool Architecture
Team License: 5 Floating Seats
├─ Developer A: Session 1 (coditect-rollout-master) ✅ ACTIVE
├─ Developer B: Session 2 (client-project-1) ✅ ACTIVE
├─ Developer C: Session 3 (client-project-2) ✅ ACTIVE
├─ [Seat 4] - Available
└─ [Seat 5] - Available
Developer D attempts activation:
└─ SUCCESS (acquires Seat 4)
Developer E attempts activation:
└─ SUCCESS (acquires Seat 5)
Developer F attempts activation:
└─ DENIED (no seats available, shows queue position)
Seat Acquisition Flow
-
Session Initialization (
.coditect/scripts/init.sh)python3 .coditect/scripts/validate-license.py --acquire-seat -
Atomic Seat Check (Redis Lua script)
-- Redis Lua script (atomic execution)
local license_key = KEYS[1]
local session_id = ARGV[1]
local max_seats = tonumber(ARGV[2])
-- Get current active sessions
local active_sessions = redis.call('SMEMBERS', license_key .. ':active_sessions')
local current_count = #active_sessions
-- Check if session already has seat
if redis.call('SISMEMBER', license_key .. ':active_sessions', session_id) == 1 then
return {1, 'ALREADY_ACTIVE', current_count}
end
-- Check seat availability
if current_count < max_seats then
-- Acquire seat
redis.call('SADD', license_key .. ':active_sessions', session_id)
redis.call('HSET', license_key .. ':session_metadata', session_id, cjson.encode({
acquired_at = redis.call('TIME')[1],
last_heartbeat = redis.call('TIME')[1],
project_root = ARGV[3],
user_email = ARGV[4]
}))
return {1, 'ACQUIRED', current_count + 1}
else
return {0, 'NO_SEATS_AVAILABLE', current_count}
end -
Heartbeat Maintenance (background process)
# .coditect/scripts/heartbeat.py
import time
import redis
def maintain_heartbeat(license_key, session_id, interval=300):
"""Send heartbeat every 5 minutes"""
r = redis.Redis(host='redis.coditect.ai', port=6379, ssl=True)
while True:
try:
r.hset(
f"{license_key}:session_metadata",
session_id,
json.dumps({
'last_heartbeat': int(time.time()),
'project_root': os.getcwd(),
'user_email': get_user_email()
})
)
time.sleep(interval)
except Exception as e:
logging.error(f"Heartbeat failed: {e}")
break -
Automatic Release (on session end)
# .coditect/scripts/release-seat.py
def release_seat(license_key, session_id):
"""Release seat when session ends"""
r = redis.Redis(host='redis.coditect.ai', port=6379, ssl=True)
# Atomic release
pipe = r.pipeline()
pipe.srem(f"{license_key}:active_sessions", session_id)
pipe.hdel(f"{license_key}:session_metadata", session_id)
pipe.execute()
logging.info(f"Seat released for session {session_id}")
Zombie Session Cleanup
Problem: Developer closes laptop without releasing seat.
Solution: TTL-based cleanup with Celery periodic tasks.
# backend/licenses/tasks.py
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
@shared_task
def cleanup_zombie_sessions():
"""Clean up sessions with stale heartbeats (every 1 minute)"""
r = redis.Redis(host=settings.REDIS_HOST, port=6379, ssl=True)
now = int(time.time())
ttl_seconds = 360 # 6 minutes
# Get all active licenses
licenses = License.objects.filter(status='active')
for license_obj in licenses:
license_key = license_obj.license_key
# Get all session metadata
session_metadata = r.hgetall(f"{license_key}:session_metadata")
for session_id, metadata_json in session_metadata.items():
metadata = json.loads(metadata_json)
last_heartbeat = metadata.get('last_heartbeat', 0)
# Check if heartbeat stale (>6 minutes old)
if now - last_heartbeat > ttl_seconds:
# Zombie session detected - release seat
r.srem(f"{license_key}:active_sessions", session_id)
r.hdel(f"{license_key}:session_metadata", session_id)
logging.warning(
f"Zombie session cleaned up: {session_id} "
f"(last heartbeat: {now - last_heartbeat}s ago)"
)
# Record event
LicenseEvent.objects.create(
license=license_obj,
event_type='session_timeout',
metadata={'session_id': session_id, 'reason': 'stale_heartbeat'}
)
Offline Grace Period
Problem: Developers need to work offline.
Solution: Tier-based offline grace periods with signed local cache.
# .coditect/scripts/validate-license.py
def check_offline_grace(cached_license):
"""Check if offline grace period still valid"""
# Verify signature (tamper protection)
if not verify_signature(cached_license):
return False, "License cache tampered"
# Check offline expiration
offline_expires_at = datetime.fromisoformat(cached_license['offline_expires_at'])
now = datetime.now(timezone.utc)
if now < offline_expires_at:
time_remaining = offline_expires_at - now
hours_remaining = int(time_remaining.total_seconds() / 3600)
if hours_remaining < 24:
print(f"⚠️ Offline grace period expires in {hours_remaining} hours")
return True, f"Valid (offline mode, {hours_remaining}h remaining)"
else:
return False, "Offline grace period expired - internet connection required"
Offline Grace Periods by Tier:
- Free: 24 hours
- Pro: 72 hours (3 days)
- Team: 48 hours (2 days)
- Enterprise: 168 hours (7 days)
Consequences
Positive
✅ Better License Utilization
- Team of 10 developers with 5 floating seats vs. 10 node-locked licenses
- Cost savings: $290/mo (5 seats) vs. $580/mo (10 seats) = 50% reduction
- Seats automatically available when developers finish work
✅ Improved Developer Experience
- No manual license activation/deactivation
- Work on any machine without re-licensing
- Transparent seat availability (real-time dashboard)
✅ Offline Development Support
- Grace periods allow offline work for reasonable durations
- Signed cache prevents tampering
- Clear warnings before grace period expiration
✅ Fair Pricing for Symlink Architecture
- Symlink resolution prevents counting same project multiple times
- Session ID based on
project_root + resolved_coditect_path - 1 developer, 1 project, 100 symlinks = 1 seat (fair)
✅ Automatic Cleanup
- Zombie sessions released after 6 minutes of inactivity
- No manual intervention required
- Seats always available for active developers
✅ Scalability
- Redis atomic operations handle thousands of concurrent seat checks
- No database bottleneck (license checks go to Redis, not PostgreSQL)
- Sub-millisecond seat acquisition latency
Negative
⚠️ Network Dependency
- Initial seat acquisition requires internet connection
- Offline grace period limited by tier
- Developers in air-gapped environments need special handling
⚠️ Seat Contention
- Large teams may experience "no seats available" errors during peak hours
- Requires team coordination or purchasing more seats
- Queue system needed for fairness
⚠️ Complexity
- Redis Lua scripts more complex than simple database queries
- Heartbeat background process adds operational overhead
- Zombie cleanup requires Celery infrastructure
⚠️ Grace Period Abuse Risk
- Developers could work offline indefinitely by resetting system clock
- Mitigation: signature includes timestamp, clock skew detection
- Enterprise tier: shorter grace period (7 days) to prevent abuse
Neutral
🔄 Heartbeat Overhead
- 5-minute heartbeat interval = ~0.003% network overhead
- Redis heartbeat = ~500 bytes every 5 minutes
- Negligible impact on developer experience
🔄 Redis Dependency
- Adds Redis as critical infrastructure component
- Uptime requirement: 99.9% (same as API server)
- Failover: Redis Sentinel or Memorystore automatic failover
Alternatives Considered
Alternative 1: Node-Locked Licenses
Description: License tied to specific hardware (MAC address, CPU ID, disk UUID).
Pros:
- ✅ Simpler implementation (no Redis, no heartbeats)
- ✅ No network dependency after initial activation
- ✅ Predictable license usage (1 machine = 1 license)
Cons:
- ❌ Poor license utilization (idle machines waste licenses)
- ❌ Hardware changes require re-activation (new laptop, VM migration)
- ❌ Doesn't fit developer workflow (work on desktop + laptop + cloud VM)
- ❌ Customer friction (support requests for re-activation)
Rejected Because: Poor fit for modern developer workflows where developers use multiple machines.
Alternative 2: Named User Licenses
Description: License tied to specific email address, unlimited machines per user.
Pros:
- ✅ Great developer experience (work on any machine)
- ✅ Simple implementation (email-based authentication)
- ✅ No seat contention within user's own machines
Cons:
- ❌ Revenue leakage (unlimited sharing via shared email accounts)
- ❌ Doesn't support team collaboration (no seat sharing across developers)
- ❌ Difficult to enforce for enterprise (shared team emails common)
Rejected Because: Revenue protection concerns and doesn't support team flexibility.
Alternative 3: Hybrid Model (Node-Locked + Floating)
Description: Each developer gets 2 node-locked activations + access to floating pool.
Pros:
- ✅ Best of both worlds (predictable + flexible)
- ✅ Offline work on primary machines (node-locked)
- ✅ Occasional use on other machines (floating pool)
Cons:
- ❌ Much more complex implementation (two licensing systems)
- ❌ Confusing for users (which license type am I using?)
- ❌ Higher operational overhead (manage two systems)
Rejected Because: Complexity outweighs benefits for v1.0. Revisit for v2.0 based on customer feedback.
Alternative 4: Time-Based Licenses (No Seat Limits)
Description: License valid for specific time period (monthly/annual), no concurrent seat limits.
Pros:
- ✅ Simplest implementation (just check expiration date)
- ✅ Best developer experience (no seat contention)
- ✅ Predictable revenue (subscription-based)
Cons:
- ❌ Revenue leakage (unlimited sharing across team)
- ❌ Difficult to scale pricing by team size
- ❌ Doesn't align with market expectations (competitors use seat-based)
Rejected Because: Revenue model doesn't scale with team size (100-person team pays same as 1-person).
Implementation Notes
Redis Configuration
Persistence: AOF + RDB for durability
# redis.conf
appendonly yes
appendfsync everysec
save 900 1
save 300 10
save 60 10000
Cluster Mode: Not required for v1.0 (single Redis instance sufficient for <10K concurrent sessions)
Sentinel/Failover: GCP Memorystore STANDARD tier provides automatic failover
Monitoring & Alerts
Key Metrics:
license_seats_available{license_key}- Available seats per licenselicense_seat_acquisition_latency_ms- Time to acquire seat (p50, p95, p99)license_zombie_sessions_cleaned- Zombie sessions cleaned per minutelicense_seat_denials_total- Total seat acquisition denials
Alerts:
# Prometheus alert rules
groups:
- name: license_seats
rules:
- alert: HighSeatUtilization
expr: (license_seats_used / license_seats_total) > 0.9
for: 5m
annotations:
summary: "License {{ $labels.license_key }} at 90%+ utilization"
description: "Consider purchasing more seats"
- alert: FrequentSeatDenials
expr: rate(license_seat_denials_total[5m]) > 1
for: 5m
annotations:
summary: "License {{ $labels.license_key }} experiencing frequent denials"
description: "Team may need more seats"
User Notifications
Email Alerts:
- 90% seat utilization → notify license admin
- Repeated seat denials (>5 in 1 hour) → suggest purchasing more seats
- Offline grace period <24 hours → remind developer to connect
Dashboard Indicators:
- Real-time seat availability chart
- Active sessions list with user email + project
- Historical utilization graph (hourly/daily)
Related ADRs
- ADR-002: Redis Lua Scripts for Atomic Operations
- ADR-003: Check-on-Init Enforcement Pattern
- ADR-004: Symlink Resolution Strategy
- ADR-005: Builder vs. Runtime Licensing Model
References
- SLASCONE Floating Licenses
- Redis Lua Scripting
- Flexera Concurrent Licensing Best Practices
- GCP Memorystore for Redis
Last Updated: 2025-11-30 Owner: Architecture Team Review Cycle: Quarterly or on major licensing changes