ADR-017 — Audit Trail Architecture: FSM Domain Logic vs REST API
ADR-017 — Audit Trail Architecture: FSM Domain Logic vs REST API
Sección titulada «ADR-017 — Audit Trail Architecture: FSM Domain Logic vs REST API»Accepted
Context
Sección titulada «Context»Coveris uses django-fsm for Employee lifecycle (6 states, 12 transitions — [[PRD]] §5.2.2) and Assignment lifecycle (3 states, 4 transitions — PRD §5.3.2). PRD §7.5 (Trazabilidad) requires every state transition to generate an immutable audit record: previous state, new state, actor, reason, timestamp.
Two concerns arise:
- Capture — intercepting every FSM transition and persisting an audit entry. Domain-level, tightly coupled to FSM models.
- Query — exposing audit logs via REST with filters and role-based access. API-level, independent of how entries are created.
An early design combined both in apps/fsm/. The FSM mixin, signal handler, log model, and REST viewset all lived together — API changes required touching the same app that defines FSM state graphs.
Decision
Sección titulada «Decision»Split audit trail into two apps with distinct responsibilities:
apps/fsm/ — Domain Logic (write side). Owns the FSM machinery. Contains:
employee_fsm.py—EmployeeFSMMixinwithdjango-fsmtransitions,EmployeeStatusenum,ProposalTypeenum. Mixed into theEmployeemodel viaapps/api/models.py.models.py—EmployeeTransitionLog(append-only:employee,from_status,to_status,transition,actor,reason,metadata,created_at).signals.py—@receiver(post_transition, sender=Employee)creates anEmployeeTransitionLogafter every FSM transition. Reads_actor,_reason,_metadataset on the instance before the transition.management/commands/— Housekeeping commands (e.g.,expire_proposals).
No URLs, no views, no serializers — never routed. Pure domain module consumed via model imports and signal side effects.
apps/audit_log/ — REST API (read side). Owns the public query interface. Contains:
views.py—AuditLogViewSet(read-only) with filters:transition,from_status,to_status,employee,search,date_from,date_to. RBAC: EMPLOYEE role sees only their own transitions.serializers.py—AuditLogEntrySerializerwith computed fields (employee_name,employee_number,actor_name).urls.py— Registerstransitions/route viaSimpleRouter.
Reads EmployeeTransitionLog from apps.fsm.models; never writes. Writes belong exclusively to the FSM signal.
Assignment audit follows the same pattern. apps/assignments/ owns the Assignment FSM, AssignmentTransitionLog model, and signal. Assignment-scoped endpoints (/{id}/transitions/, /{id}/available-transitions/) stay in the same viewset because they are nested under the assignment resource. If a global assignment audit log is needed, a second read-only viewset in apps/audit_log/ can query AssignmentTransitionLog — no structural change.
Routing reflects the split.
path('api/v1/', include('apps.audit_log.urls')), # /api/v1/transitions/# apps/fsm/ is NOT routed — it has no URLsConsequences
Sección titulada «Consequences»- FSM logic changes (new states, transitions, guards) only touch
apps/fsm/. The REST API is unaffected. - API changes (new filters, serializer fields, endpoints) only touch
apps/audit_log/. The FSM domain is unaffected. - Testing is isolated: FSM tests verify transitions and signal firing; audit_log tests verify HTTP responses, filters, and RBAC.
- Future audit models (org-unit changes, contract changes) follow the same split: domain app owns model + signal,
audit_loggains a viewset. - Dependency is one-way:
audit_logreads fromfsm;fsmis unaware ofaudit_log.