Skip to main content

ADR-003: Check-on-Init Enforcement Pattern

Status: Accepted Date: 2025-11-30 Deciders: Architecture Team, Product Team, DevOps Team Tags: licensing, enforcement, user-experience, docker


Context

CODITECT-CORE uses a local-first architecture - the framework runs entirely on the user's machine (bare metal, VM, or Docker container). The license server in the cloud only validates licenses and tracks concurrent usage; it does NOT host or execute the framework.

This raises the critical question: When and how do we enforce license validation?

Enforcement Options

1. Per-Command Validation

  • Check license before every /command or agent invocation
  • Maximum security (every action validated)
  • Maximum friction (100+ validations per session)

2. Background Polling

  • Continuous license validation every N seconds
  • Proactive detection of license changes
  • Network overhead and battery drain

3. Check-on-Init (Session Startup)

  • Validate license once when CODITECT starts
  • Minimal friction (one check per session)
  • Relies on session lifecycle management

4. Honor System (No Enforcement)

  • Trust users to comply with license terms
  • Zero friction
  • Zero protection against abuse

User Experience Requirements

Developer Workflow:

Morning:
09:00 - Developer starts workday, opens terminal
09:01 - Starts CODITECT session (first license check)
09:05 - Uses 20+ commands over 3 hours
12:00 - Lunch break (session idle)
13:00 - Resumes work (no re-check needed)
18:00 - Ends workday, closes CODITECT

Expected license checks: 1 (on startup)
Unacceptable: 100+ checks throughout the day

Docker/Container Use Case:

Developer workflow with Docker:
09:00 - docker-compose up -d (container starts)
09:01 - Container initialization runs .coditect/scripts/init.sh
└─ License check happens HERE (once)
09:05 - docker-compose exec coditect zsh (enter container)
└─ NO license check (already validated)
09:10 - Use CODITECT commands/agents for 8 hours
└─ NO license checks (session already validated)
18:00 - docker-compose down (container stops, seat released)

Expected license checks: 1 (on container startup)
Result: Zero friction during development

Technical Constraints

  • CODITECT installed as git submodule (.coditect/)
  • Symlink chains across multiple projects
  • Offline development required (flights, air-gapped environments)
  • Docker containers may restart frequently (development workflow)
  • No modification to user's shell profile or system settings

Decision

We will use Check-on-Init Enforcement Pattern with heartbeat-based session tracking.

License validation occurs once per session when .coditect/scripts/init.sh runs (either manually or automatically on container startup). Subsequent heartbeats (every 5 minutes) maintain the session, but do NOT block command execution.

Implementation Architecture

Session Lifecycle (Bare Metal or Docker):

1. User starts CODITECT
└─ Triggers: .coditect/scripts/init.sh

2. init.sh checks license
├─ Check cached license (.coditect/.license-cache)
├─ Validate signature (offline-capable)
└─ If invalid → Call license API (acquire seat)

3. License acquired/validated
├─ Start heartbeat background process (every 5 min)
├─ Cache signed license locally
└─ Continue CODITECT startup

4. Developer uses CODITECT (no further checks)
├─ Run commands (no license validation)
├─ Invoke agents (no license validation)
└─ Heartbeat sends in background (non-blocking)

5. Session ends
└─ Graceful shutdown releases seat
OR heartbeat TTL (6 min) auto-releases

init.sh Integration (Core Implementation)

File: .coditect/scripts/init.sh

#!/bin/bash
# CODITECT Initialization Script
# Runs on session startup (manual or Docker container start)

set -e

# ============================================
# SECTION 1: License Validation (NEW)
# ============================================

echo "🔒 Validating CODITECT license..."

# Step 1: Check for cached license
if [ -f .coditect/.license-cache ]; then
python3 .coditect/sdk/license_client.py validate --cached
if [ $? -eq 0 ]; then
echo "✅ License valid (cached)"

# Start heartbeat in background
python3 .coditect/sdk/license_client.py heartbeat --daemon &
HEARTBEAT_PID=$!
echo $HEARTBEAT_PID > .coditect/.heartbeat.pid

# Continue to existing init.sh sections
echo ""
echo "📦 CODITECT Framework v1.0"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

# ... rest of init.sh (environment checks, component activation, etc.)
exit 0
fi
fi

# Step 2: Online validation (no cached license or cache invalid)
echo "⚡ Contacting license server..."
python3 .coditect/sdk/license_client.py acquire
if [ $? -eq 0 ]; then
echo "✅ License activated"

# Start heartbeat in background
python3 .coditect/sdk/license_client.py heartbeat --daemon &
HEARTBEAT_PID=$!
echo $HEARTBEAT_PID > .coditect/.heartbeat.pid

# Continue to existing init.sh sections
echo ""
echo "📦 CODITECT Framework v1.0"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

# ... rest of init.sh
exit 0
fi

# Step 3: Offline grace period (if online validation fails)
echo "⚠️ License server unreachable, checking offline grace period..."
python3 .coditect/sdk/license_client.py validate --offline-grace
if [ $? -eq 0 ]; then
GRACE_HOURS=$(python3 .coditect/sdk/license_client.py grace-remaining)
echo "⚠️ License valid (offline mode, ${GRACE_HOURS}h remaining)"
echo " Please connect to the internet soon to renew."

# Continue with limited functionality
echo ""
echo "📦 CODITECT Framework v1.0 (Offline Mode)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"

# ... rest of init.sh
exit 0
fi

# Step 4: License invalid (no valid license, grace period expired)
echo "❌ CODITECT License Required"
echo ""
echo "Your CODITECT license is invalid or expired."
echo ""
echo "To activate your license:"
echo " 1. Visit: https://coditect.ai/activate"
echo " 2. Sign in with your account"
echo " 3. Copy your license key"
echo " 4. Run: coditect activate <license-key>"
echo ""
echo "Need help? Contact: support@coditect.ai"
echo ""
exit 1

Docker Integration (Seamless)

Docker Compose: docker-compose.yml

version: '3.8'

services:
coditect:
build: .
container_name: coditect-dev
hostname: coditect-dev

# License validation happens in entrypoint
entrypoint: ["/app/.coditect/scripts/init.sh"]
command: ["zsh"]

volumes:
# Mount .coditect for hot-reload
- ./.coditect:/app/.coditect

# Persist license cache across container restarts
- coditect-license-cache:/app/.coditect/.license-cache:rw

environment:
# License client configuration
- CODITECT_LICENSE_API=https://license.coditect.ai
- CODITECT_OFFLINE_GRACE_HOURS=72 # Pro tier default

networks:
- coditect-network

volumes:
coditect-license-cache:
driver: local

networks:
coditect-network:
driver: bridge

Dockerfile: Dockerfile

FROM ubuntu:22.04

# Install dependencies
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
git \
curl \
zsh \
# ... other dependencies

# Copy CODITECT framework
COPY ./.coditect /app/.coditect

# Install license client SDK
RUN pip3 install -r /app/.coditect/sdk/requirements.txt

# Set working directory
WORKDIR /app

# License validation happens in init.sh (called by entrypoint)
# No hardcoded license keys in Dockerfile (security best practice)

# Graceful shutdown handler (releases seat on container stop)
COPY ./.coditect/scripts/shutdown.sh /shutdown.sh
RUN chmod +x /shutdown.sh

# Container lifecycle
ENTRYPOINT ["/app/.coditect/scripts/init.sh"]
CMD ["zsh"]

# On SIGTERM (docker stop), release seat
STOPSIGNAL SIGTERM

Graceful Shutdown: .coditect/scripts/shutdown.sh

#!/bin/bash
# Release license seat on container shutdown

echo "🛑 Shutting down CODITECT container..."

# Stop heartbeat process
if [ -f .coditect/.heartbeat.pid ]; then
HEARTBEAT_PID=$(cat .coditect/.heartbeat.pid)
kill $HEARTBEAT_PID 2>/dev/null || true
rm .coditect/.heartbeat.pid
fi

# Release seat
python3 .coditect/sdk/license_client.py release

echo "✅ License seat released"
exit 0

License Client SDK (Phase 4)

File: .coditect/sdk/license_client.py

#!/usr/bin/env python3
"""
CODITECT License Client SDK
Handles license validation, seat acquisition, and heartbeat management.
"""

import os
import sys
import json
import time
import hashlib
import platform
import subprocess
import requests
from datetime import datetime, timedelta
from pathlib import Path

class LicenseClient:
"""Client SDK for CODITECT license management."""

def __init__(self):
self.api_url = os.getenv('CODITECT_LICENSE_API', 'https://license.coditect.ai')
self.cache_file = Path('.coditect/.license-cache')
self.session_id = self.generate_session_id()

def generate_session_id(self):
"""Generate unique session ID (symlink-aware)."""

# Resolve symlinks to physical path
coditect_path = os.path.realpath('.coditect')

# Get project root (git repository root)
try:
project_root = subprocess.check_output(
['git', 'rev-parse', '--show-toplevel'],
cwd=os.getcwd()
).decode().strip()
except:
project_root = os.getcwd()

# Hardware fingerprinting
hardware_id = self.get_hardware_id()

# User identification
user_email = self.get_user_email()

# Session data
session_data = {
'user_email': user_email,
'hardware_id': hardware_id,
'project_root': project_root,
'coditect_path': coditect_path, # RESOLVED PATH
'coditect_version': self.get_coditect_version(),
'usage_type': 'builder' # or 'runtime'
}

# Hash to generate stable session ID
session_id = hashlib.sha256(
json.dumps(session_data, sort_keys=True).encode()
).hexdigest()

return session_id

def get_hardware_id(self):
"""Get hardware fingerprint (MAC + CPU + machine UUID)."""
import uuid

# MAC address (primary network interface)
mac = ':'.join(['{:02x}'.format((uuid.getnode() >> ele) & 0xff)
for ele in range(0, 8*6, 8)][::-1])

# CPU info (platform-specific)
if platform.system() == 'Darwin': # macOS
cpu_info = subprocess.check_output(['sysctl', '-n', 'machdep.cpu.brand_string']).decode().strip()
elif platform.system() == 'Linux':
with open('/proc/cpuinfo', 'r') as f:
cpu_info = [line for line in f if 'model name' in line][0].split(':')[1].strip()
else:
cpu_info = platform.processor()

# Machine UUID
machine_uuid = str(uuid.UUID(int=uuid.getnode()))

# Combine and hash
hardware_data = f"{mac}:{cpu_info}:{machine_uuid}"
hardware_id = hashlib.sha256(hardware_data.encode()).hexdigest()[:32]

return hardware_id

def get_user_email(self):
"""Get user email from git config."""
try:
email = subprocess.check_output(
['git', 'config', 'user.email']
).decode().strip()
return email
except:
return os.getenv('USER', 'unknown') + '@localhost'

def get_coditect_version(self):
"""Get CODITECT version from .coditect/VERSION."""
try:
with open('.coditect/VERSION', 'r') as f:
return f.read().strip()
except:
return '1.0.0'

def validate_cached_license(self):
"""Validate cached license (offline-capable)."""
if not self.cache_file.exists():
return False

try:
with open(self.cache_file, 'r') as f:
cached_license = json.load(f)

# Verify signature (tamper protection)
if not self.verify_signature(cached_license):
print("❌ Cached license signature invalid (tampered)")
return False

# Check expiration
expires_at = datetime.fromisoformat(cached_license['expires_at'])
if datetime.now() > expires_at:
print("❌ Cached license expired")
return False

# Check offline grace period
offline_expires_at = datetime.fromisoformat(cached_license.get('offline_expires_at', cached_license['expires_at']))
if datetime.now() > offline_expires_at:
print("❌ Offline grace period expired")
return False

return True

except Exception as e:
print(f"❌ Error validating cached license: {e}")
return False

def verify_signature(self, license_data):
"""Verify license signature using public key."""
# TODO: Implement RSA signature verification with Cloud KMS public key
# For now, placeholder
return 'signature' in license_data

def acquire_license(self):
"""Acquire license seat from API."""
try:
response = requests.post(
f"{self.api_url}/v1/licenses/acquire",
json={
'session_id': self.session_id,
'hardware_id': self.get_hardware_id(),
'user_email': self.get_user_email(),
'project_root': os.getcwd(),
'coditect_version': self.get_coditect_version()
},
timeout=10
)

if response.status_code == 200:
license_data = response.json()

# Cache license locally
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.cache_file, 'w') as f:
json.dump(license_data, f, indent=2)

return True

elif response.status_code == 429:
# No seats available
error_data = response.json()
print(f"❌ No license seats available")
print(f" Seats used: {error_data['seats_used']}/{error_data['seats_total']}")
if error_data.get('wait_queue_position'):
print(f" Queue position: {error_data['wait_queue_position']}")
return False

else:
print(f"❌ License activation failed: {response.text}")
return False

except requests.exceptions.Timeout:
print("❌ License server timeout (network issue)")
return False
except Exception as e:
print(f"❌ License acquisition error: {e}")
return False

def check_offline_grace(self):
"""Check if offline grace period still valid."""
if not self.cache_file.exists():
return False

try:
with open(self.cache_file, 'r') as f:
cached_license = json.load(f)

# Verify signature
if not self.verify_signature(cached_license):
return False

# Check offline expiration
offline_expires_at = datetime.fromisoformat(cached_license.get('offline_expires_at'))
now = datetime.now()

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
else:
return False

except Exception as e:
print(f"❌ Error checking offline grace: {e}")
return False

def heartbeat(self, daemon=False):
"""Send heartbeat to maintain session."""
if daemon:
# Run as background daemon
while True:
self._send_heartbeat()
time.sleep(300) # 5 minutes
else:
# One-time heartbeat
self._send_heartbeat()

def _send_heartbeat(self):
"""Internal: Send single heartbeat."""
try:
response = requests.post(
f"{self.api_url}/v1/licenses/heartbeat",
json={'session_id': self.session_id},
timeout=5
)

if response.status_code != 200:
print(f"⚠️ Heartbeat failed: {response.text}")

except Exception as e:
# Silent failure (non-blocking)
pass

def release(self):
"""Release license seat."""
try:
response = requests.post(
f"{self.api_url}/v1/licenses/release",
json={'session_id': self.session_id},
timeout=5
)

if response.status_code == 200:
# Remove cached license
if self.cache_file.exists():
self.cache_file.unlink()
return True

except Exception:
pass

return False

# CLI interface
if __name__ == '__main__':
import argparse

parser = argparse.ArgumentParser(description='CODITECT License Client')
parser.add_argument('action', choices=['validate', 'acquire', 'heartbeat', 'release', 'grace-remaining'])
parser.add_argument('--cached', action='store_true', help='Validate cached license')
parser.add_argument('--offline-grace', action='store_true', help='Check offline grace period')
parser.add_argument('--daemon', action='store_true', help='Run heartbeat as daemon')

args = parser.parse_args()
client = LicenseClient()

if args.action == 'validate':
if args.cached:
sys.exit(0 if client.validate_cached_license() else 1)
elif args.offline_grace:
sys.exit(0 if client.check_offline_grace() else 1)

elif args.action == 'acquire':
sys.exit(0 if client.acquire_license() else 1)

elif args.action == 'heartbeat':
client.heartbeat(daemon=args.daemon)

elif args.action == 'release':
sys.exit(0 if client.release() else 1)

elif args.action == 'grace-remaining':
# Output hours remaining in grace period
if client.cache_file.exists():
with open(client.cache_file, 'r') as f:
cached_license = json.load(f)

offline_expires_at = datetime.fromisoformat(cached_license.get('offline_expires_at'))
time_remaining = offline_expires_at - datetime.now()
hours_remaining = int(time_remaining.total_seconds() / 3600)

print(hours_remaining)
sys.exit(0)
else:
print(0)
sys.exit(1)

Consequences

Positive

Minimal User Friction

  • License check happens once per session (not per command)
  • Docker users: license validated on docker-compose up (transparent)
  • Bare metal users: license validated on manual .coditect/scripts/init.sh call
  • Zero interruptions during active development (8-hour sessions common)

Offline Development Support

  • Cached signed licenses work without network
  • Grace periods by tier: Free (24h), Pro (72h), Enterprise (168h)
  • Developers can work on flights, in coffee shops, air-gapped environments

Docker-Native Integration

  • Init script runs automatically on container startup (entrypoint)
  • License cache persisted via Docker volume (survives container restarts)
  • Graceful shutdown releases seat (SIGTERM handler)
  • No manual activation needed (seamless developer experience)

Fair Pricing via Symlink Resolution

  • Session ID based on resolved physical path + project_root
  • 1 developer, 1 project, 100 symlinks = 1 session = 1 seat
  • Prevents overbilling for symlink-heavy architectures

Automatic Zombie Cleanup

  • Heartbeat TTL (6 minutes) auto-releases stale sessions
  • Container crashes don't leak seats (TTL cleanup)
  • Graceful shutdown releases seat immediately (on docker-compose down)

Security: No Client-Side Secrets

  • No license keys hardcoded in Dockerfile (security best practice)
  • Signed licenses cached locally (tamper-proof via Cloud KMS signature)
  • Public key verification client-side (no secret key exposure)

Negative

⚠️ Initial Network Dependency

  • First license acquisition requires internet connection
  • Mitigation: Offline grace period allows 24-168h offline work
  • Docker: License cache volume persists across container restarts

⚠️ Container Restart Overhead

  • Each docker-compose up triggers license validation
  • Adds ~1-2 seconds to container startup time
  • Mitigation: Cached license makes subsequent startups faster (<100ms)

⚠️ Grace Period Expiration Risk

  • Developers working offline >grace period locked out
  • Mitigation: Clear warnings when grace period <24h remaining
  • Mitigation: Email alerts to license admin before expiration

⚠️ Heartbeat Background Process

  • Additional process running in container
  • Memory overhead: ~10MB (Python heartbeat daemon)
  • CPU overhead: Negligible (~0.01% CPU every 5 min)

Neutral

🔄 Session Definition

  • Session = one init.sh invocation (one container startup or one manual call)
  • Long-lived sessions (8+ hours) common and expected
  • Session ends when container stops or user manually releases

🔄 License Check Frequency

  • Initial: On session start (1 check)
  • Ongoing: Heartbeat every 5 minutes (non-blocking, background)
  • Never: Per-command validation (too much friction)

Alternatives Considered

Alternative 1: Per-Command Validation

Implementation:

# Every command/agent invocation
/analyze → License check → Execute (if valid)
/implement → License check → Execute (if valid)
# ... 100+ checks per session

Pros:

  • ✅ Maximum security (every action validated)
  • ✅ Real-time license revocation (immediate effect)

Cons:

  • ❌ Extreme user friction (100+ network calls per session)
  • ❌ Unusable offline (every command fails without network)
  • ❌ High latency (10-50ms per command for license check)
  • ❌ Network overhead (1000+ API calls per day per developer)

Rejected Because: Unacceptable user experience. Developers would find alternative tools.

Alternative 2: Background Polling (Continuous Validation)

Implementation:

# Background process polls license server every 30 seconds
while True:
validate_license()
time.sleep(30)

Pros:

  • ✅ Real-time license revocation (30-second delay)
  • ✅ No per-command overhead

Cons:

  • ❌ High network overhead (2,880 API calls per day per developer)
  • ❌ Battery drain (mobile/laptop developers)
  • ❌ Redis load (1000+ developers = 50K+ checks/minute)
  • ❌ Unusable offline (continuous polling fails)

Rejected Because: Excessive network overhead and battery drain for minimal benefit.

Alternative 3: Honor System (No Enforcement)

Implementation:

# No license validation
# Trust users to comply with terms

Pros:

  • ✅ Zero friction (developers never interrupted)
  • ✅ Simplest implementation
  • ✅ Works offline always

Cons:

  • ❌ Zero revenue protection (unlimited sharing possible)
  • ❌ No usage analytics (can't track adoption)
  • ❌ Unfair to paying customers (non-compliant users get same access)

Rejected Because: Business model requires license enforcement. Cannot sustain freemium tier without limits.

Alternative 4: Shell Profile Integration

Implementation:

# Add to ~/.zshrc or ~/.bashrc
source /path/to/coditect/.coditect/scripts/license_check.sh

Pros:

  • ✅ Automatic validation on every shell startup
  • ✅ No manual init.sh call needed

Cons:

  • ❌ Modifies user's shell configuration (invasive)
  • ❌ Difficult to uninstall cleanly
  • ❌ Doesn't work with Docker (container has its own shell)
  • ❌ Fragile (users can remove or bypass)

Rejected Because: Too invasive. Docker environments don't use user's host shell profile.


Implementation Notes

Docker Volume Persistence

Problem: Container restarts lose cached license.

Solution: Named volume for license cache.

volumes:
coditect-license-cache:
driver: local

Mount point: .coditect/.license-cache

Benefit: Cached license persists across docker-compose down/up cycles.

Graceful Shutdown Registration

Problem: Docker SIGTERM may not call shutdown.sh.

Solution: Register signal handler in init.sh.

# .coditect/scripts/init.sh

# Trap SIGTERM and SIGINT
trap 'python3 .coditect/sdk/license_client.py release; exit 0' TERM INT

# Continue with session
# ...

Benefit: Seat released even if shutdown.sh not called.

Heartbeat Failure Handling

Problem: Network interruption during heartbeat.

Solution: Silent failure, retry next interval.

def _send_heartbeat(self):
try:
response = requests.post(...)
if response.status_code != 200:
# Log warning but don't crash
logging.warning(f"Heartbeat failed: {response.text}")
except Exception as e:
# Silent failure (non-blocking)
pass

Benefit: Temporary network issues don't crash CODITECT.

Multi-Container Environments

Docker Compose with multiple services:

services:
coditect:
# Main CODITECT service (license validated)
entrypoint: ["/app/.coditect/scripts/init.sh"]

postgres:
# Database service (no license needed)
image: postgres:16

redis:
# Cache service (no license needed)
image: redis:7

Only coditect service validates license - supporting services do NOT need licenses.


  • ADR-001: Floating Licenses vs. Node-Locked Licenses (context for seat management)
  • ADR-002: Redis Lua Scripts for Atomic Operations (seat acquisition atomicity)
  • ADR-004: Symlink Resolution Strategy (session ID generation)
  • ADR-005: Builder vs. Runtime Licensing Model (usage_type field in session data)

References


Last Updated: 2025-11-30 Owner: Architecture Team, Product Team, DevOps Team Review Cycle: Quarterly or on Docker/license infrastructure changes