ADR-016: Version-Based Licensing
Status: Accepted Date: 2025-11-30 Deciders: Product Team, Engineering Team Tags: versioning, compatibility, upgrades, deprecation
Context
Software Version Compatibility Challenge
CODITECT releases new versions regularly with features, bug fixes, and breaking changes. Licenses must enforce version compatibility to:
Business Requirements:
- Version Gating: Free tier limited to older versions, Pro/Team get latest
- Update Enforcement: Force critical security updates
- Deprecation Management: Sunset old versions gracefully
- Beta Access: Grant early access to Pro/Enterprise customers
Technical Challenges:
- Version Drift: Customer on v1.5 tries to use v2.0 agents (incompatible)
- Security Vulnerabilities: Customer refuses to update despite critical CVE
- Support Burden: Supporting 10+ versions simultaneously is unsustainable
Real-World Scenario
Customer A (Free Tier)
- Using CODITECT v1.5.0
- Tries to upgrade to v2.0.0
- License API: "Free tier limited to v1.x, upgrade to Pro for v2.0"
- Result: Blocked from v2.0 until upgrade
Customer B (Pro Tier)
- Using CODITECT v1.8.0
- Security vulnerability CVE-2025-1234 in v1.8.0
- License API: "Critical update required, v1.8.0 deprecated"
- Grace period: 30 days to update to v1.9.0+
- Day 30: License blocked until update
Customer C (Enterprise Tier)
- Beta access enabled
- Using CODITECT v2.1.0-beta
- License API: "Beta access granted, v2.1.0-beta allowed"
- Result: Early access to features
Version Policy Requirements
Free Tier:
- Limited to N-1 major version (e.g., v1.x when v2.0 is latest)
- Security updates only (no new features)
- Update window: 90 days after major release
Pro Tier:
- Access to latest stable version
- Early access to beta (opt-in)
- Update window: 60 days for major updates
Team/Enterprise:
- Access to all versions including beta
- Extended update windows (180 days)
- Custom version pinning for compliance
Deprecation Policy:
- 90-day notice before version sunset
- Security updates for deprecated versions (90 days)
- Hard cutoff after deprecation period
Decision
We will implement version-based licensing with:
- Semantic Versioning Enforcement (major.minor.patch)
- Tier-Based Version Gating (Free = N-1, Pro = latest, Enterprise = all)
- Deprecation Timeline Management (90-day notice + grace period)
- Security Update Enforcement (mandatory for critical CVEs)
- Beta Access Control (opt-in for Pro/Enterprise)
Version Compatibility Architecture
┌────────────────────────────────────────────────────────────────┐
│ Version Licensing Flow │
└────────────────────────────────────────────────────────────────┘
User Starts CODITECT v2.0.0
│
│ POST /api/v1/licenses/validate
│ {
│ "license_key": "LIC-...",
│ "version": "2.0.0",
│ "hardware_id": "sha256..."
│ }
▼
┌───────────────────────┐
│ License API │
│ │
│ 1. Validate license │
│ 2. Check tier │
│ 3. Get version rules │
└───────┬───────────────┘
│
│ Version Rules Query
▼
┌───────────────────────┐
│ version_rules table │
│ │
│ tier: free │
│ allowed_versions: │
│ - "1.*" │
│ blocked_versions: │
│ - "2.*" │
└───────┬───────────────┘
│
│ Version: 2.0.0
│ Tier: Free
│ Rule: "1.*" allowed, "2.*" blocked
▼
┌───────────────────────┐
│ Version Check │
│ │
│ 2.0.0 matches "2.*"? │
│ → YES │
│ │
│ "2.*" blocked for │
│ Free tier? │
│ → YES │
└───────┬───────────────┘
│
│ Version blocked ❌
▼
┌───────────────────────┐
│ Error Response │
│ │
│ { │
│ "valid": false, │
│ "error": │
│ "version_blocked"│
│ "message": │
│ "Free tier │
│ limited to v1.x"│
│ "upgrade_url": │
│ "https://..." │
│ } │
└───────────────────────┘
Version Deprecation Timeline
┌────────────────────────────────────────────────────────────────┐
│ Version Deprecation Lifecycle │
└────────────────────────────────────────────────────────────────┘
Day 0: Version Released (v2.0.0)
│
│ Status: ACTIVE
│ All tiers: Full access
│
▼
Day 180: Next Major Release (v3.0.0)
│
│ v2.0.0 Status: ACTIVE (still supported)
│ v3.0.0 Status: ACTIVE
│
▼
Day 270: Deprecation Notice (90 days before sunset)
│
│ v2.0.0 Status: DEPRECATED
│ 📧 Email: "v2.0.0 will be sunset in 90 days"
│ CLI Warning: "⚠️ Version deprecated, please upgrade"
│
▼
Day 300: 60 Days Remaining
│
│ 📧 Email: "v2.0.0 sunset in 60 days"
│ CLI Warning: More frequent reminders
│
▼
Day 330: 30 Days Remaining
│
│ 📧 Email: "URGENT: v2.0.0 sunset in 30 days"
│ CLI Warning: Daily reminders
│
▼
Day 360: Version Sunset (Hard Cutoff)
│
│ v2.0.0 Status: SUNSET
│ License blocked: "Version v2.0.0 no longer supported"
│ Must upgrade to v3.0.0+ to continue
│
└─► Version fully disabled ❌
Implementation
1. Database Schema
File: backend/licenses/models.py
from django.db import models
from django.utils import timezone
import re
import uuid
class VersionRule(models.Model):
"""
Version compatibility rules per tier.
Defines which versions are allowed/blocked for each tier.
"""
class Status(models.TextChoices):
ACTIVE = 'active', 'Active'
DEPRECATED = 'deprecated', 'Deprecated'
SUNSET = 'sunset', 'Sunset'
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tier = models.ForeignKey('Tier', on_delete=models.CASCADE, related_name='version_rules')
# Semantic version pattern (supports wildcards)
# Examples: "2.0.0", "2.*", "2.0.*", ">=2.0.0", "<3.0.0"
version_pattern = models.CharField(max_length=50, db_index=True)
# Allow or block this version pattern
allowed = models.BooleanField(default=True)
# Version status
status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE)
# Deprecation dates
deprecated_at = models.DateTimeField(null=True, blank=True)
sunset_at = models.DateTimeField(null=True, blank=True)
# Metadata
release_date = models.DateField(null=True, blank=True)
release_notes_url = models.URLField(max_length=500, blank=True)
class Meta:
db_table = 'version_rules'
ordering = ['-release_date']
unique_together = [['tier', 'version_pattern']]
def __str__(self):
action = "Allow" if self.allowed else "Block"
return f"{action} {self.version_pattern} for {self.tier.name}"
def matches_version(self, version: str) -> bool:
"""
Check if version matches this rule's pattern.
Args:
version: Semantic version string (e.g., "2.0.1")
Returns:
True if version matches pattern, False otherwise
Patterns:
- "2.0.0" - Exact match
- "2.*" - Any v2.x.x
- "2.0.*" - Any v2.0.x
- ">=2.0.0" - v2.0.0 or higher
- "<3.0.0" - Below v3.0.0
"""
pattern = self.version_pattern
# Exact match
if pattern == version:
return True
# Wildcard match
if '*' in pattern:
regex_pattern = pattern.replace('.', r'\.').replace('*', r'\d+')
return bool(re.match(f"^{regex_pattern}$", version))
# Comparison operators
if pattern.startswith('>='):
return self._compare_versions(version, '>=', pattern[2:])
elif pattern.startswith('<='):
return self._compare_versions(version, '<=', pattern[2:])
elif pattern.startswith('>'):
return self._compare_versions(version, '>', pattern[1:])
elif pattern.startswith('<'):
return self._compare_versions(version, '<', pattern[1:])
return False
def _compare_versions(self, v1: str, operator: str, v2: str) -> bool:
"""
Compare semantic versions.
Args:
v1: First version (e.g., "2.0.1")
operator: Comparison operator ('>', '<', '>=', '<=')
v2: Second version (e.g., "2.0.0")
Returns:
True if comparison holds, False otherwise
"""
def parse_version(v):
parts = v.split('.')
return tuple(int(p) for p in parts[:3]) # major, minor, patch
v1_tuple = parse_version(v1)
v2_tuple = parse_version(v2)
if operator == '>':
return v1_tuple > v2_tuple
elif operator == '<':
return v1_tuple < v2_tuple
elif operator == '>=':
return v1_tuple >= v2_tuple
elif operator == '<=':
return v1_tuple <= v2_tuple
return False
class VersionDeprecation(models.Model):
"""
Track version deprecation notices and timeline.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
version = models.CharField(max_length=50, unique=True, db_index=True)
deprecated_at = models.DateTimeField()
sunset_at = models.DateTimeField() # Hard cutoff date
# Notification tracking
notice_sent_90d = models.BooleanField(default=False)
notice_sent_60d = models.BooleanField(default=False)
notice_sent_30d = models.BooleanField(default=False)
notice_sent_7d = models.BooleanField(default=False)
# Deprecation reason
reason = models.TextField()
security_critical = models.BooleanField(default=False)
cve_ids = models.JSONField(default=list) # List of CVE IDs
class Meta:
db_table = 'version_deprecations'
ordering = ['-deprecated_at']
def __str__(self):
return f"Deprecation: {self.version} (sunset: {self.sunset_at.date()})"
class License(models.Model):
"""Extended License model with version tracking."""
# ... existing fields ...
# Version tracking
allowed_versions = models.JSONField(default=list)
# Examples: ["1.*", "2.0.*", ">=2.0.0"]
beta_access_enabled = models.BooleanField(default=False)
# Pro/Enterprise can opt-in to beta versions
last_version_used = models.CharField(max_length=50, blank=True)
last_version_check_at = models.DateTimeField(null=True, blank=True)
# Version enforcement
enforce_version_check = models.BooleanField(default=True)
# If False, version checks are warnings only
def is_version_allowed(self, version: str) -> tuple[bool, str]:
"""
Check if version is allowed for this license.
Args:
version: Semantic version string (e.g., "2.0.1")
Returns:
(allowed, reason) tuple
- allowed: True if version is allowed, False otherwise
- reason: Explanation if blocked
Logic:
1. Check if version is sunset (hard block)
2. Check tier-specific version rules
3. Check beta access for beta versions
4. Default: allow if no blocking rules
"""
# Check if version is sunset
try:
deprecation = VersionDeprecation.objects.get(version=version)
if timezone.now() > deprecation.sunset_at:
return False, f"Version {version} has been sunset. Please upgrade to latest version."
except VersionDeprecation.DoesNotExist:
pass
# Check tier version rules
rules = VersionRule.objects.filter(tier=self.tier)
for rule in rules:
if rule.matches_version(version):
if not rule.allowed:
return False, f"Version {version} not available for {self.tier.name} tier. Upgrade to access latest features."
# Check if deprecated (warning, not block)
if rule.status == VersionRule.Status.DEPRECATED:
# Return allowed but with warning
days_until_sunset = (rule.sunset_at - timezone.now()).days if rule.sunset_at else 0
return True, f"⚠️ Version {version} is deprecated and will be sunset in {days_until_sunset} days. Please upgrade."
# Check beta access for beta/alpha versions
if 'beta' in version.lower() or 'alpha' in version.lower():
if not self.beta_access_enabled:
return False, f"Beta access not enabled. Contact support to enable beta versions."
# Default: allow
return True, "Version allowed"
2. Version Validation Endpoint
File: backend/licenses/views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from .models import License, VersionDeprecation
@api_view(['POST'])
def validate_license_version(request):
"""
Validate license with version check.
Request Body:
{
"license_key": "LIC-...",
"version": "2.0.1",
"hardware_id": "sha256..."
}
Response (Success):
{
"valid": true,
"version_allowed": true,
"version_status": "active",
"message": "License valid for version 2.0.1",
"tier": "pro",
"expires_at": "2025-12-30T00:00:00Z"
}
Response (Version Blocked):
{
"valid": false,
"version_allowed": false,
"version_status": "blocked",
"message": "Version 2.0.1 not available for Free tier",
"upgrade_url": "https://coditect.ai/upgrade",
"recommended_version": "1.9.0"
}
Response (Version Deprecated):
{
"valid": true,
"version_allowed": true,
"version_status": "deprecated",
"warning": "Version 2.0.1 deprecated, sunset in 45 days",
"recommended_version": "3.0.0",
"sunset_date": "2026-01-15"
}
"""
license_key = request.data.get('license_key')
version = request.data.get('version')
hardware_id = request.data.get('hardware_id')
# Validate required fields
if not all([license_key, version, hardware_id]):
return Response(
{'error': 'license_key, version, and hardware_id required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get license
try:
license_obj = License.objects.select_related('tier').get(
license_key=license_key
)
except License.DoesNotExist:
return Response(
{'error': 'Invalid license key'},
status=status.HTTP_404_NOT_FOUND
)
# Check license status (active, expired, etc.)
if license_obj.status != License.Status.ACTIVE:
return Response({
'valid': False,
'version_allowed': False,
'message': f"License {license_obj.status}"
}, status=status.HTTP_403_FORBIDDEN)
# Check version compatibility
version_allowed, version_message = license_obj.is_version_allowed(version)
# Update last version used
license_obj.last_version_used = version
license_obj.last_version_check_at = timezone.now()
license_obj.save()
if not version_allowed:
# Version blocked
return Response({
'valid': False,
'version_allowed': False,
'version_status': 'blocked',
'message': version_message,
'upgrade_url': f"{settings.FRONTEND_URL}/upgrade",
'recommended_version': get_recommended_version(license_obj.tier),
'tier': license_obj.tier.slug
}, status=status.HTTP_403_FORBIDDEN)
# Check if deprecated (warning, but still allowed)
is_deprecated = False
deprecation_warning = None
try:
deprecation = VersionDeprecation.objects.get(version=version)
is_deprecated = True
days_until_sunset = (deprecation.sunset_at - timezone.now()).days
deprecation_warning = {
'message': f"Version {version} will be sunset in {days_until_sunset} days",
'sunset_date': deprecation.sunset_at,
'recommended_version': get_recommended_version(license_obj.tier),
'security_critical': deprecation.security_critical,
'cve_ids': deprecation.cve_ids
}
except VersionDeprecation.DoesNotExist:
pass
# Version allowed
response_data = {
'valid': True,
'version_allowed': True,
'version_status': 'deprecated' if is_deprecated else 'active',
'message': version_message,
'tier': license_obj.tier.slug,
'expires_at': license_obj.expires_at,
'max_seats': license_obj.max_seats
}
if deprecation_warning:
response_data['deprecation_warning'] = deprecation_warning
return Response(response_data, status=status.HTTP_200_OK)
def get_recommended_version(tier) -> str:
"""
Get recommended version for tier.
Args:
tier: Tier object
Returns:
Recommended version string
Logic:
- Free: Latest v1.x (N-1 major version)
- Pro: Latest stable
- Team/Enterprise: Latest stable
"""
from .version_utils import get_latest_version
if tier.slug == 'free':
return get_latest_version(major=1) # Latest v1.x
else:
return get_latest_version() # Latest stable
3. Deprecation Management Task
File: backend/licenses/tasks.py
from celery import shared_task
from django.utils import timezone
from datetime import timedelta
import logging
from .models import VersionDeprecation, License
from .notifications import (
send_deprecation_notice_email,
send_sunset_warning_email
)
logger = logging.getLogger(__name__)
@shared_task(
bind=True,
name='licenses.tasks.check_version_deprecations',
soft_time_limit=300,
time_limit=360
)
def check_version_deprecations(self):
"""
Daily task to check version deprecations and send notices.
Schedule: Daily at 00:15 UTC
Workflow:
1. Get all active deprecations
2. For each deprecation:
- Check days until sunset
- Send notice emails at 90d, 60d, 30d, 7d
- Update notification flags
3. Find customers on deprecated versions
4. Send sunset warnings
Returns:
Dict with processing statistics
"""
try:
logger.info("Starting version deprecation check")
stats = {
'deprecations_checked': 0,
'notices_sent_90d': 0,
'notices_sent_60d': 0,
'notices_sent_30d': 0,
'notices_sent_7d': 0,
'customers_affected': 0
}
# Get all active deprecations
deprecations = VersionDeprecation.objects.filter(
sunset_at__gt=timezone.now()
)
now = timezone.now()
for deprecation in deprecations:
stats['deprecations_checked'] += 1
days_until_sunset = (deprecation.sunset_at - now).days
# 90-day notice
if days_until_sunset <= 90 and not deprecation.notice_sent_90d:
customers = send_deprecation_notices(
deprecation.version,
days_remaining=days_until_sunset,
security_critical=deprecation.security_critical
)
deprecation.notice_sent_90d = True
deprecation.save()
stats['notices_sent_90d'] += 1
stats['customers_affected'] += customers
logger.info(
f"Sent 90-day deprecation notice",
extra={'version': deprecation.version, 'customers': customers}
)
# 60-day notice
elif days_until_sunset <= 60 and not deprecation.notice_sent_60d:
customers = send_deprecation_notices(
deprecation.version,
days_remaining=days_until_sunset,
security_critical=deprecation.security_critical
)
deprecation.notice_sent_60d = True
deprecation.save()
stats['notices_sent_60d'] += 1
stats['customers_affected'] += customers
# 30-day notice
elif days_until_sunset <= 30 and not deprecation.notice_sent_30d:
customers = send_deprecation_notices(
deprecation.version,
days_remaining=days_until_sunset,
urgency='high',
security_critical=deprecation.security_critical
)
deprecation.notice_sent_30d = True
deprecation.save()
stats['notices_sent_30d'] += 1
stats['customers_affected'] += customers
# 7-day notice (final warning)
elif days_until_sunset <= 7 and not deprecation.notice_sent_7d:
customers = send_deprecation_notices(
deprecation.version,
days_remaining=days_until_sunset,
urgency='critical',
security_critical=deprecation.security_critical
)
deprecation.notice_sent_7d = True
deprecation.save()
stats['notices_sent_7d'] += 1
stats['customers_affected'] += customers
logger.info(
f"Version deprecation check completed",
extra=stats
)
return stats
except Exception as e:
logger.error(
f"Error in version deprecation check",
extra={'error': str(e)},
exc_info=True
)
return {'error': str(e)}
def send_deprecation_notices(version: str, days_remaining: int, urgency='normal', security_critical=False) -> int:
"""
Send deprecation notice emails to customers on specified version.
Args:
version: Version being deprecated (e.g., "2.0.0")
days_remaining: Days until sunset
urgency: 'normal', 'high', 'critical'
security_critical: If True, emphasize security urgency
Returns:
Number of customers notified
"""
# Find all licenses using this version
customers = License.objects.filter(
last_version_used=version,
status=License.Status.ACTIVE
).select_related('tenant')
count = 0
for license_obj in customers:
try:
send_deprecation_notice_email(
license_obj,
version=version,
days_remaining=days_remaining,
urgency=urgency,
security_critical=security_critical
)
count += 1
except Exception as e:
logger.error(
f"Failed to send deprecation notice",
extra={
'license_key': license_obj.license_key,
'error': str(e)
}
)
return count
4. CLI Version Check (Client-Side)
File: .coditect/scripts/check-version.py
#!/usr/bin/env python3
"""
Check CODITECT version compatibility with license.
"""
import sys
import json
import requests
from pathlib import Path
def get_current_version() -> str:
"""Get current CODITECT version from package.json or VERSION file."""
version_file = Path('.coditect/VERSION')
if version_file.exists():
return version_file.read_text().strip()
return "unknown"
def check_version_compatibility(license_key: str, version: str, api_url: str) -> dict:
"""
Check if current version is compatible with license.
Returns:
{
'compatible': True/False,
'version_status': 'active'/'deprecated'/'blocked',
'message': 'Human-readable message',
'recommended_version': '3.0.0',
'upgrade_url': 'https://...'
}
"""
response = requests.post(
f"{api_url}/api/v1/licenses/validate-version",
json={
'license_key': license_key,
'version': version,
'hardware_id': get_hardware_id()
},
timeout=10
)
if response.status_code == 200:
data = response.json()
return {
'compatible': data['version_allowed'],
'version_status': data.get('version_status', 'active'),
'message': data.get('message', ''),
'deprecation_warning': data.get('deprecation_warning')
}
elif response.status_code == 403:
data = response.json()
return {
'compatible': False,
'version_status': 'blocked',
'message': data.get('message', 'Version not allowed'),
'recommended_version': data.get('recommended_version'),
'upgrade_url': data.get('upgrade_url')
}
else:
return {
'compatible': False,
'version_status': 'error',
'message': f"Version check failed: {response.status_code}"
}
def display_version_status(status: dict):
"""Display version compatibility status."""
if not status['compatible']:
print(f"\n❌ VERSION NOT COMPATIBLE")
print(f"Message: {status['message']}")
if status.get('recommended_version'):
print(f"Recommended Version: {status['recommended_version']}")
if status.get('upgrade_url'):
print(f"Upgrade: {status['upgrade_url']}")
sys.exit(1)
elif status.get('deprecation_warning'):
warning = status['deprecation_warning']
print(f"\n⚠️ VERSION DEPRECATED")
print(f"Message: {warning['message']}")
print(f"Sunset Date: {warning['sunset_date']}")
print(f"Recommended Version: {warning['recommended_version']}")
if warning.get('security_critical'):
print(f"\n🚨 SECURITY CRITICAL UPDATE REQUIRED")
print(f"CVE IDs: {', '.join(warning['cve_ids'])}")
else:
print(f"✅ Version compatible ({status['version_status']})")
if __name__ == '__main__':
license_key = Path('.coditect/license.key').read_text().strip()
version = get_current_version()
api_url = 'https://api.coditect.ai'
status = check_version_compatibility(license_key, version, api_url)
display_version_status(status)
Consequences
Positive
✅ Controlled Rollout
- Free tier on stable N-1 version
- Pro/Enterprise get latest features
- Beta access for early adopters
✅ Security Enforcement
- Critical CVE patches enforced
- Deprecated versions sunset automatically
- 90-day notice before hard cutoff
✅ Support Sustainability
- Support only 2-3 versions simultaneously
- Clear deprecation timeline
- Automatic customer migration to latest
✅ Revenue Driver
- Version gating incentivizes upgrades
- "Upgrade to Pro for v2.0" conversion funnel
- Clear value differentiation
Negative
⚠️ Customer Friction
- Forced updates may disrupt workflows
- Breaking changes require migration effort
- 90-day window may be insufficient for enterprises
⚠️ Implementation Complexity
- Semantic version parsing
- Deprecation timeline management
- Multi-tier version rules
Related ADRs
- ADR-010: Feature Gating Matrix (version-based feature access)
- ADR-012: License Expiration and Renewal (update enforcement)
References
Last Updated: 2025-11-30 Owner: Product Team, Engineering Team Review Cycle: Quarterly