Ir al contenido

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

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:

  1. Capture — intercepting every FSM transition and persisting an audit entry. Domain-level, tightly coupled to FSM models.
  2. 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.

Split audit trail into two apps with distinct responsibilities:

apps/fsm/ — Domain Logic (write side). Owns the FSM machinery. Contains:

  • employee_fsm.pyEmployeeFSMMixin with django-fsm transitions, EmployeeStatus enum, ProposalType enum. Mixed into the Employee model via apps/api/models.py.
  • models.pyEmployeeTransitionLog (append-only: employee, from_status, to_status, transition, actor, reason, metadata, created_at).
  • signals.py@receiver(post_transition, sender=Employee) creates an EmployeeTransitionLog after every FSM transition. Reads _actor, _reason, _metadata set 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.pyAuditLogViewSet (read-only) with filters: transition, from_status, to_status, employee, search, date_from, date_to. RBAC: EMPLOYEE role sees only their own transitions.
  • serializers.pyAuditLogEntrySerializer with computed fields (employee_name, employee_number, actor_name).
  • urls.py — Registers transitions/ route via SimpleRouter.

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.

config/urls.py
path('api/v1/', include('apps.audit_log.urls')), # /api/v1/transitions/
# apps/fsm/ is NOT routed — it has no URLs
  • 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_log gains a viewset.
  • Dependency is one-way: audit_log reads from fsm; fsm is unaware of audit_log.