ADR-019 — Tag-Based Position Requirements and Employee Attributes
ADR-019 — Tag-Based Position Requirements and Employee Attributes
Sección titulada «ADR-019 — Tag-Based Position Requirements and Employee Attributes»Accepted
Context
Sección titulada «Context»Coveris tracks demand through positions (required_weekly_hours) and supply through employee attributes (contracts, reductions, qualifications). [[PRD]] §8.5 defers skills and certifications to a future decision.
Three problems motivated this design:
-
Positions cannot express contract type requirements. A position declares only hours. An ICU position requiring “Jornada Insalubre” (36h, hazardous-duty regime) is indistinguishable from an administrative position requiring 36h. Without typed requirements, the system allows any employee with available hours to be assigned, regardless of contract regime — violating Argentine labor regulations (Ley 9539, Circular 4 — [[adr-021-argentine-legal-context|ADR-021]]) for restricted environments.
-
Identical hours mask distinct regimes. “Guardia 24h” (continuous 24h block) and “Medio Tiempo 24h” (distributed half-time) both contribute 24h to the pool. Without contract type awareness, a guard station requiring continuous-presence coverage appears fully covered by a half-time administrative employee.
-
Separate models per dimension do not scale. Modeling contract types, certifications, reductions, and qualifications as independent model hierarchies multiplies tables, serializers, viewsets, and tests for each new dimension.
The underlying insight: contracts, reductions, certifications, and skills are the same primitive — a named label with an optional hours impact. They share identical attachment and evaluation semantics and belong in a single unified system.
Decision
Sección titulada «Decision»1. TagCatalog — the unified primitive. All employee attributes and position requirements are represented as typed tags from a centrally managed catalog.
TagCatalog: name VARCHAR(255), UNIQUE -- "Guardia 12h", "Médico", "ACLS" (short, badge-friendly) display_name VARCHAR(120), blank -- "Guardia Parcial 12h", "Profesional médico/a matriculado/a" category VARCHAR(30) -- CONTRACT, QUALIFICATION, EXCEPTION, CERTIFICATION hours_delta DECIMAL(6,2), default=0 -- +12, -4, 0 (signed, per week) description TEXT -- Human context is_active BOOLEAN, default=True -- Soft-disablecategory enables behavioral constraints per group (e.g., business rule “max 1 active CONTRACT tag” is enforced by category, not by name). Categories are an open enum — new categories can be added without schema migration.
hours_delta is the tag’s weekly hours contribution:
- Positive: adds to pool (contracts —
Contrato 36h→ +36.00) - Negative: reduces pool (reductions —
Sindicalizado→ −12.00) - Zero: pure qualifier (certifications, skills —
is_medic→ 0.00)
Namespace is catalog-controlled. Tags on employees and positions must reference existing TagCatalog entries. Clinic admins can add entries. Free-text tag creation is not allowed — this prevents “is_medic” / “es_medico” / “Médico” drift.
2. EmployeeTag — what the employee IS.
EmployeeTag: employee FK(Employee, CASCADE) tag FK(TagCatalog, PROTECT) start_date DateField end_date DateField, nullable -- NULL = indefinite status VARCHAR(20) -- ACTIVE, EXPIRED, REVOKED[!note] API Responses include computed read-only fields
tag_name(short label) andtag_display_name(long form) sourced from the relatedTagCatalogrow. See [[API]] §employee-tags.
No unique constraint on (employee, tag). Stacking is a feature: an employee can hold two “Guardia 12h” tags (24h total pool). Each instance is a separate record with its own lifecycle.
Temporal awareness: Tags with bounded date ranges participate in pro-rating when partially overlapping an ISO-week computation period. The pro-rating logic is applied uniformly to all tags.
Lifecycle: Tags are typically assigned during ONBOARDING but can be added/removed at any employee state. start_date/end_date control when the tag is computationally active. status tracks manual revocation (REVOKED) and automated expiry (EXPIRED).
3. PositionTag — what the position REQUIRES.
PositionTag: position FK(Position, CASCADE) tag FK(TagCatalog, PROTECT) is_mandatory BOOLEAN, default=True UNIQUE(position, tag) -- A position requires a tag or it doesn't[!note] API Responses include computed read-only fields
tag_name,tag_display_name, andtag_categorysourced from the relatedTagCatalogrow. See [[API]] §position-tags.
is_mandatory = True → assignment is blocked if employee lacks this tag (default BLOCKING severity, admin-configurable to WARNING via business rule TAG_REQUIREMENT_MISMATCH).
is_mandatory = False → preference, not hard requirement. Generates WARNING on mismatch, never blocks.
Empty requirements set → any employee can be assigned (backwards compatible with current behavior).
4. Hours pool computation — additive stacking. The hours pool is computed by simple summation of active tag deltas:
active_tags = EmployeeTag.objects.filter( employee=employee, status='ACTIVE', start_date__lte=period_end,).filter(Q(end_date__isnull=True) | Q(end_date__gte=period_start))
effective_pool = max(Decimal('0'), sum(t.tag.hours_delta for t in active_tags))No priority ordering. No decorator pattern. Tags are commutative — order of application does not affect the result. The floor-at-zero clamp is applied once after summation.
Pro-rating for partial-week overlap is preserved: a tag whose start_date or end_date falls within the ISO week contributes a proportional fraction of its hours_delta.
5. Assignment eligibility — set containment. A new business rule TAG_REQUIREMENT_MISMATCH (registered in apps/business_rules/) evaluates at assignment preview and creation:
required_tags = PositionTag.objects.filter(position=pos, is_mandatory=True)employee_tag_names = set( et.tag.name for et in EmployeeTag.objects.filter( employee=emp, status='ACTIVE', ... ))missing = {rt.tag.name for rt in required_tags} - employee_tag_namesif missing: return violation(code='TAG_REQUIREMENT_MISMATCH', missing_tags=list(missing))Default severity: BLOCKING. Clinic admins can downgrade to WARNING via the business rules control panel ([[adr-016-capacity-planning-patterns|ADR-016]] §1 pattern).
6. Coverage stays one-dimensional. Coverage remains: Σ(assigned_hours) / Σ(required_hours) × 100. Tags are eligibility filters (checked before assignment creation), not coverage dimensions. No changes to coverage aggregation, frontend coverage.service.ts, or CoverageSummaryResponse API shape.
7. Relationship with [[adr-016-capacity-planning-patterns|ADR-016]]. This ADR owns all contract, reduction, certification, and qualification modeling. ADR-016 owns the business rules engine (§1) and coverage alerts (§2) — both are extended by this ADR, not duplicated.
The tag system is the sole mechanism for employee attributes and position requirements. There are no separate contract models (ContractTemplate, ContractInstance, ContractAdjustment) — those concepts are expressed as typed tags.
8. Model mapping. The tag system unifies what would otherwise be separate model hierarchies:
| Concept | Tag Representation |
|---|---|
| Contract type | TagCatalog row with category=CONTRACT, positive hours_delta |
| Employee’s contract | EmployeeTag row linking employee to contract tag |
| Reduction / exception | EmployeeTag row with category=EXCEPTION, negative hours_delta |
| Attribute change audit | TagChangeLog (generic audit for all tag mutations) |
9. Seeded tags. 23 seed entries across 4 categories: 9 CONTRACT, 3 EXCEPTION, 9 QUALIFICATION, 2 CERTIFICATION.
| Name | Display Name | Category | hours_delta |
|---|---|---|---|
| Admin 40h | Jornada Administrativa 40h | CONTRACT | +40.00 |
| MT 24h | Medio Tiempo 24h | CONTRACT | +24.00 |
| Insalubre | Jornada Insalubre 36h | CONTRACT | +36.00 |
| MT 20h | Medio Tiempo 20h | CONTRACT | +20.00 |
| Completa 48h | Jornada Completa 48h | CONTRACT | +48.00 |
| Admin 45h | Jornada Administrativa 45h | CONTRACT | +45.00 |
| Asistencial 30h | Jornada Asistencial 30h | CONTRACT | +30.00 |
| Guardia 24h | Guardia Activa 24h | CONTRACT | +24.00 |
| Guardia 12h | Guardia Parcial 12h | CONTRACT | +12.00 |
| Sindicalizado | Reducción gremial −12h/sem | EXCEPTION | −12.00 |
| Lactancia | Reducción por lactancia −4h/sem | EXCEPTION | −4.00 |
| Lic. Sin Goce | Licencia sin goce de haberes | EXCEPTION | −36.00 |
| Médico | Profesional médico/a matriculado/a | QUALIFICATION | 0.00 |
| Enfermero | Profesional enfermero/a matriculado/a | QUALIFICATION | 0.00 |
| Contador | Profesional contador/a matriculado/a | QUALIFICATION | 0.00 |
| Cirujano | Cirujano/a habilitado/a | QUALIFICATION | 0.00 |
| Anestesiólogo | Médico/a anestesiólogo/a | QUALIFICATION | 0.00 |
| Téc. en Anestesia | Técnico/a en anestesia | QUALIFICATION | 0.00 |
| Instrumentador | Instrumentador/a quirúrgico/a | QUALIFICATION | 0.00 |
| Intensivista | Médico/a intensivista | QUALIFICATION | 0.00 |
| Camillero | Camillero/a | QUALIFICATION | 0.00 |
| ACLS | Advanced Cardiovascular Life Support | CERTIFICATION | 0.00 |
| PALS | Pediatric Advanced Life Support | CERTIFICATION | 0.00 |
10. Position default. Position.required_weekly_hours gains default=Decimal('36.00'). This is the most common clinical contract base in the target market. The field remains editable — the default is a convenience, not a constraint.
11. Audit trail. Following [[adr-017-audit-trail-architecture|ADR-017]]‘s pattern (domain writes, API reads):
TagChangeLog: employee FK(Employee, CASCADE) tag FK(TagCatalog, PROTECT) action VARCHAR(20) -- ASSIGNED, REMOVED, REVOKED, EXPIRED, MODIFIED actor FK(User, SET_NULL, nullable) reason TEXT, blank=True created_at DateTimeField, auto_now_add[!note] API Responses include computed read-only fields
tag_name(short label),tag_display_name(long form), andtag_categorysourced from the relatedTagCatalogrow. See [[API]] §tag-change-log.
Append-only. Created by a Django signal on EmployeeTag post_save/post_delete. Read via apps/audit_log/ viewset ([[adr-017-audit-trail-architecture|ADR-017]] split preserved).
Consequences
Sección titulada «Consequences»- Adding a new requirement type (e.g., language proficiency, driver’s license) requires: one
TagCatalogrow insert. No migration, no serializer, no viewset. - Three tables cover all dimensions.
TagCatalog+EmployeeTag+PositionTagexpress contracts, reductions, certifications, qualifications, and any future attribute types without additional models. - Hours pool computation is additive.
max(0, Σ(deltas))— no priority ordering, no decorator pipeline, no apply() methods. - API endpoints.
TagCatalogCRUD,EmployeeTagCRUD,PositionTagCRUD. Hours balance response shape is stable (pool, consumption, balance, state). - [[adr-016-capacity-planning-patterns|ADR-016]] §1 (Business Rules) and §2 (Coverage Alerts) remain valid. This ADR owns all tag and attribute modeling; ADR-016 owns the rule engine and coverage alerting.
- [[adr-011-weekly-only-compute|ADR-011]] (Weekly-Only Computation) is preserved. All tag
hours_deltavalues are per-week. Monthly views remain read-time aggregations. - [[adr-017-audit-trail-architecture|ADR-017]] (Audit Trail) pattern is extended with
TagChangeLogfollowing the same write-in-domain / read-via-API split. - [[adr-018-orgunit-demand-scope|ADR-018]] (Demand Scope) is unchanged. Positions remain at UNIT-type leaves.
PositionTagattaches to positions, not org units. - Coverage aggregation is unchanged. Tags filter eligibility at assignment time; coverage remains one-dimensional (hours).