Skip to main content

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:

  1. Floating/Concurrent Licenses - Shared pool of seats, first-come-first-served
  2. 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

  1. Session Initialization (.coditect/scripts/init.sh)

    python3 .coditect/scripts/validate-license.py --acquire-seat
  2. 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
  3. 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
  4. 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 license
  • license_seat_acquisition_latency_ms - Time to acquire seat (p50, p95, p99)
  • license_zombie_sessions_cleaned - Zombie sessions cleaned per minute
  • license_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)

  • 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


Last Updated: 2025-11-30 Owner: Architecture Team Review Cycle: Quarterly or on major licensing changes