Skip to main content

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:

  1. Semantic Versioning Enforcement (major.minor.patch)
  2. Tier-Based Version Gating (Free = N-1, Pro = latest, Enterprise = all)
  3. Deprecation Timeline Management (90-day notice + grace period)
  4. Security Update Enforcement (mandatory for critical CVEs)
  5. 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

  • 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