Plan Enforcement
Runtime enforcement of workspace plan limits using Redis atomic counters, subscription-aware billing periods, lazy DB reconciliation, and fail-closed error handling.
Overview
Every workflow execution passes through PlanEnforcementService before it can run. The service checks:
| Check | When | Limit source |
|---|---|---|
| Execution quota | Before execution starts | WorkspacePlan.MaxExecutions |
| Concurrent executions | Before execution starts | WorkspacePlan.MaxConcurrentExecutions |
| Tasks per execution | During Run() loop | WorkspacePlan.MaxTasksPerExecution |
| Execution timeout | During Run() loop | WorkspacePlan.MaxExecutionDurationMinutes |
| Payload size | During Run() loop | WorkspacePlan.MaxExecutionPayloadSizeMb |
Pre-execution checks (quota + concurrent) use Redis atomic counters. Runtime checks (tasks, timeout, payload) query the in-memory plan cache directly.
Architecture
Component Overview
Data Model
Redis Key Schema
Execution Flow
Pre-Execution Enforcement
Runtime Enforcement (during Run loop)
Concurrent Counter Lifecycle
Caching Layer
WorkspacePlanCacheService
WorkspacePlanInfo DTO
record WorkspacePlanInfo(
WorkspacePlan? Plan,
Guid? ActiveSubscriptionId,
DateTime PeriodStart,
DateTime PeriodEnd
)
- Plan — the
workspace_planrow with all limit values - ActiveSubscriptionId — the subscription GUID used as Redis key discriminator
- PeriodStart / PeriodEnd — billing period from active subscription; fallback to calendar month if no subscription
Lua Scripts
SeedAndIncrIfUnderLimitScript
Used for the execution quota counter. Handles lazy reconciliation atomically.
-- KEYS[1] = counter key
-- ARGV[1] = limit
-- ARGV[2] = TTL in seconds
-- ARGV[3] = seed value (-1 = skip seeding)
local exists = redis.call('EXISTS', KEYS[1])
if exists == 0 and tonumber(ARGV[3]) >= 0 then
redis.call('SET', KEYS[1], ARGV[3])
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
end
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= tonumber(ARGV[1]) then
return -1 -- limit reached
end
local newVal = redis.call('INCR', KEYS[1])
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return newVal -- new count after increment
Behavior:
- If key doesn't exist and seed >= 0 → SET key to seed value
- Check if current count >= limit → return -1 (blocked)
- Otherwise INCR and return new value
IncrIfUnderLimitScript
Used for the concurrent execution counter (no seeding needed).
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
if current >= tonumber(ARGV[1]) then
return -1
end
local newVal = redis.call('INCR', KEYS[1])
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return newVal
UpdatePeakScript
Tracks the highest concurrent execution count seen in the billing period.
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local newVal = tonumber(ARGV[1])
if newVal > current then
redis.call('SET', KEYS[1], newVal)
if tonumber(ARGV[2]) > 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
end
return current
Subscription Lifecycle and Counter Reset
The execution counter is keyed by subscription GUID. When a subscription renews, a new row with a new GUID is created, which means the Redis counter key is naturally fresh.
Stripe Subscription Lifecycle
Enterprise / System Auto-Renewal
Error Codes
| Code | Enum | Meaning |
|---|---|---|
| 9001 | PlanExecutionLimitReached | Billing period execution quota exhausted |
| 9002 | PlanConcurrentExecutionLimitReached | Too many simultaneous executions |
| 9003 | PlanTaskPerExecutionLimitReached | Single execution exceeded max tasks |
| 9004 | PlanExecutionTimeout | Execution exceeded max duration |
| 9005 | PlanRateLimitExceeded | Rate limit exceeded (reserved) |
| 9006 | PlanPayloadSizeExceeded | Request/response payload too large |
| 9007 | PlanStorageLimitReached | Blob storage limit exceeded |
| 9008 | PlanEnforcementUnavailable | Redis unavailable, execution blocked (fail-closed) |
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Redis key discriminator | ActiveSubscriptionId (GUID) | Auto-resets on renewal, no date ambiguity |
| Redis failure mode | Fail-closed (block execution) | Prevents quota abuse when enforcement is unavailable |
| Reconciliation strategy | Lazy (on key miss) | No cron needed; DB query only on rare cache miss |
| Seed atomicity | Lua script with EXISTS guard | Prevents race conditions between concurrent requests |
| Subscription renewal | New DB row (new GUID) | Redis counter key resets without explicit deletion |
| Cache invalidation | Redis pub/sub | Works across multiple engine instances |
| Cache TTL | 30 minutes | Balances freshness vs DB load |
| Redis counter TTL | 45 days | Outlives any billing cycle; stale keys expire naturally |
| Concurrent counter TTL | 24 hours | Self-heals if counter leaks due to crashes |
| No active subscription | Allow with defaults | Matches existing behavior for default-plan workspaces |
File Reference
| Component | Path | Role |
|---|---|---|
| IPlanEnforcementService | WorkflowEngine/Services/Interfaces/IPlanEnforcementService.cs | Interface: pre-start checks, runtime checks, decrement |
| PlanEnforcementService | WorkflowEngine/Services/PlanEnforcementService.cs | Redis counters, Lua scripts, lazy reconciliation, fail-closed |
| WorkspacePlanCacheService | WorkflowEngine/Services/WorkspacePlanCacheService.cs | In-memory cache + Redis pub/sub invalidation |
| WorkspacePlanInfo | WorkflowEngine/Services/WorkspacePlanCacheService.cs | DTO: Plan + SubscriptionId + PeriodStart + PeriodEnd |
| WorkflowService | WorkflowEngine/Services/WorkflowService.cs | Caller: Start() and Run() loop enforcement |
| CoreController | WorkflowEngine/Controllers/CoreController.cs | /api/core/invalidate-plan/{wsId} endpoint |
| WorkspacePlan | WorkflowEngineLibrary/Core/Models/WorkspacePlan.cs | Plan limits entity |
| WorkspaceSubscription | WorkflowEngineLibrary/Core/Models/WorkspaceSubscription.cs | Subscription entity (billing period, status) |
| Workspace | WorkflowEngineLibrary/Core/Models/Workspace.cs | ActiveSubscriptionId FK |
| ErrorCode | WorkflowEngineLibrary/Core/Errors/ErrorCode.cs | Plan error codes (9001-9008) |
| BillingService | WorkflowEngineWeb/Server/Services/Implementations/BillingService.cs | Subscription create/update/deactivate |
| StripeWebhookController | WorkflowEngineWeb/Server/Controllers/StripeWebhookController.cs | Stripe event handling |
| WorkspacePlanService | WorkflowEngineWeb/Server/Services/Implementations/WorkspacePlanService.cs | Plan transitions + engine notification |
| SubscriptionLifecycleCron | WorkflowEngineWeb/Server/Services/Implementations/SubscriptionLifecycleCron.cs | Auto-renewal, expiry, trial handling |
| DI Registration | WorkflowEngine/Program.cs | Singleton: WorkspacePlanCacheService, Scoped: PlanEnforcementService |
| MockPlanEnforcementService | WorkflowEngine.Test/Mocks/MockPlanEnforcementService.cs | Test mock: always allows all operations |