ADR-016 — Capacity Planning Patterns
ADR-016 — Capacity Planning Patterns
Sección titulada «ADR-016 — Capacity Planning Patterns»Accepted
Context
Sección titulada «Context»Coveris is a capacity planning system with three interconnected domains:
- Structure (demand): positions declare
required_weekly_hoursper org unit. - Roster (supply): employees carry typed tags ([[adr-019-tag-based-requirements|ADR-019]]) whose
hours_deltavalues sum to an effective weekly pool. - Assignments (bridge): each assignment declares
effective_hourslinking one employee to one position.
The central equation is:
Coverage = SUM(active assignment hours) / position.required_weekly_hoursBalance = employee.effective_pool - SUM(employee's active assignment hours)Two engineering problems arise:
-
Configurable business rules: [[adr-010-rule-severity-levels|ADR-010]] established three severity levels (BLOCKING, WARNING, INFO), but no decision was made on how rules are stored, evaluated, or toggled. Assignment preview must validate against rules like
MAX_WEEKLY_HOURS(60h cap per Argentine law, Ley 9539 — [[adr-021-argentine-legal-context|ADR-021]]) without hardcoding the cap value or severity. -
Coverage alerting: positions have four discrete states (VACANT, PARTIAL, COVERED, OVER_COVERED), but there is no mechanism to define thresholds that trigger alerts (e.g., “warn when any department drops below 80% coverage”).
Decision
Sección titulada «Decision»1. Business Rules — Specification Pattern + Rule Engine. Business rules live in a dedicated Django app: apps/business_rules/. This app has two components:
Catalogue — The data layer. A BusinessRule model stores the rule definitions:
| Field | Type | Purpose |
|---|---|---|
code | VARCHAR(50), unique | Rule identifier (e.g., MAX_WEEKLY_HOURS) |
name | VARCHAR(255) | Human-readable label |
severity | ENUM per [[adr-010-rule-severity-levels | ADR-010]] |
threshold | DECIMAL(10,2), nullable | Configurable numeric value (e.g., 60.00 for hour cap) |
enabled | BOOLEAN | Toggle on/off without code changes |
description | TEXT | Explanation for the control panel UI |
The catalogue includes a read-only REST endpoint (GET /api/v1/business-rules/, non-paginated) so the frontend can display rule information, and Django admin registration so admins can toggle/configure rules.
Engine — The evaluation layer. A registry maps each rule code to an evaluator function (Specification pattern — stateless predicates):
def evaluate_rules(context: RuleContext) -> RuleResults: results = RuleResults() for rule in BusinessRule.objects.filter(enabled=True): evaluator = RULE_REGISTRY.get(rule.code) if evaluator: result = evaluator(rule, context) results.add(rule.severity, result) return resultsOther apps consume rules by importing evaluate_rules from the engine — they never query the BusinessRule model directly for evaluation:
# In apps/assignments/views.py (preview endpoint)from apps.business_rules.engine import evaluate_rules, RuleContext
context = RuleContext(employee=emp, position=pos, effective_hours=hours)results = evaluate_rules(context)# results.is_valid → True if no BLOCKING violations# results.blocking → list of BLOCKING violations# results.warnings → list of WARNING violationsRules are seeded in data migrations (e.g., MAX_WEEKLY_HOURS with threshold=60, severity=BLOCKING) and editable in the Control Panel by ADMIN/MANAGER roles. Adding a new rule requires one data migration (seed row) and one evaluator function — no changes to existing code (Open/Closed). Toggling severity or disabling a rule requires one admin panel action — no deployment.
2. Coverage Alerts — Configurable Thresholds. Coverage states (VACANT, PARTIAL, COVERED, OVER_COVERED) are computed at read time from positions and assignments. Never cached, never stale (consistent with [[adr-011-weekly-only-compute|ADR-011]]‘s stateless computation philosophy).
For alerting, a coverage_thresholds configuration defines when each state triggers a visual indicator:
| State | Condition | Default Color | Default Severity |
|---|---|---|---|
| VACANT | ratio = 0 | Red | BLOCKING |
| PARTIAL | 0 < ratio < 1 | Amber | WARNING |
| COVERED | ratio = 1 | Green | — |
| OVER_COVERED | ratio > 1 | Blue | INFO |
MVP: thresholds are evaluated at frontend render time. The TreeTable and Drawer use COVERAGE_STATE_CONFIG to map states to PrimeNG tag severities and progress bar colors.
Future: configurable per org-unit. A coverage_alert_config table allows admins to set custom thresholds (e.g., “Guardia must be ≥90% covered, warn at 80%”). Django signals can emit notifications when coverage drops below a configured floor.
Consequences
Sección titulada «Consequences»- Adding a new business rule requires: one migration (seed row in
business_rules), oneevaluate()method. No changes to existing rules. - Toggling rule severity or disabling a rule requires: one admin action in Control Panel. No deployment.
- Coverage thresholds are hardcoded for MVP (four discrete states). Future ADR may introduce configurable thresholds per org-unit.
- [[adr-010-rule-severity-levels|ADR-010]]‘s severity levels (BLOCKING, WARNING, INFO) are reused directly — no new severity enum.
- [[adr-011-weekly-only-compute|ADR-011]]‘s weekly-only computation is preserved — all values are per-week.
- [[adr-019-tag-based-requirements|ADR-019]] (Tag-Based Requirements) handles contract types, reductions, qualifications, and certifications as typed tags with
hours_delta. This ADR focuses on the business rule engine and coverage alerting that consume those tags.