Skip to content

ADR-011-F: Compliance Auto-Blocking as Architectural Constraint

Status: Accepted Date: 2026-04-16 Decision Makers: Gautham Chellappa Depends on: ADR-002-F (Modular Monolith), ADR-005-F (Event-driven cross-domain) Related: B12 C1 — the competitor-audit finding that established this as mandatory

Context

Australian labour-hire law, workplace-health-and-safety regulation, and award compliance all share a common principle: non-compliant workers must not be rostered, paid, or charged to clients. Fasttrack360 (the direct AU labour-hire incumbent, per B12) makes compliance auto-blocking its deepest moat — not a post-hoc check, not a warning, but an architectural constraint that blocks the write at the source.

Finnest must match or exceed this. A compliance rule that fires after the shift is assigned has already failed. A notification telling the supervisor "by the way, this worker's white card expired" doesn't prevent the worker from showing up to site unlawfully.

Event-driven cross-domain communication (ADR-005-F) is asynchronous — it cannot block a write. A synchronous exception is required.

Decision

Finnest exposes one synchronous cross-domain function, called before write operations in 5 gated domains. All other cross-domain communication remains event-driven (ADR-005-F).

The function

defmodule Finnest.Compliance do
  @doc """
  Synchronously checks whether a worker holds the credentials required for an action.

  Returns:
    - :compliant — proceed with write
    - {:non_compliant, [missing_credential_codes]} — reject write
    - {:expiring_soon, [credential_codes_and_days]} — warn, proceed (or reject per policy)

  Hot path cached in Finnest.Compliance.CredentialCache (ETS) — invalidated on
  credential_updated event.
  """
  @spec check(worker_id :: UUID.t(), required_credentials :: [String.t()]) ::
          :compliant | {:non_compliant, [String.t()]} | {:expiring_soon, [map()]}
  def check(worker_id, required_credentials) do
    # ...
  end
end

Five gated caller sites

Domain Context function What gets blocked
finnest_roster Assignments.assign_worker/3 Assigning a non-credentialled worker to a shift
finnest_timekeep Timecards.submit/2, ClockEvents.record/2 Submitting hours on a shift where credentials expired since assignment
finnest_payroll PayRuns.finalise/2 Finalising pay run that includes hours for non-compliant workers
finnest_clients Invoices.generate/2 Generating client invoice lines for non-compliant worker hours
finnest_assets Assignments.assign_vehicle/3 Assigning a vehicle to a worker without a valid driver's licence (added Part 7)

Call pattern (gold standard):

defmodule Finnest.Roster.Assignments do
  def assign_worker(shift, worker, actor) do
    Repo.transaction(fn ->
      case Finnest.Compliance.check(worker.id, shift.required_credential_codes) do
        :compliant ->
          {:ok, assignment} = do_assign(shift, worker, actor)
          EventStore.append(%Event{type: :shift_assigned, ...})
          assignment

        {:non_compliant, missing} ->
          Repo.rollback({:non_compliant, missing})

        {:expiring_soon, expiring} ->
          # Per-org policy: warn-only or warn-and-reject
          case org_policy(actor.org_id, :expiring_credentials) do
            :warn_only ->
              {:ok, assignment} = do_assign(shift, worker, actor)
              EventStore.append(%Event{type: :shift_assigned, warnings: expiring, ...})
              assignment

            :reject ->
              Repo.rollback({:credentials_expiring_soon, expiring})
          end
      end
    end)
  end
end

Why this is the only synchronous exception

Events cannot block. A write that must fail if the worker isn't compliant cannot use events — by the time the event fires, the write has happened. A synchronous read is the only correct primitive.

No other current requirement has this property. Adding a second synchronous exception would require revisiting this ADR rather than quietly extending the list.

Performance

Hot path hits Finnest.Compliance.CredentialCache (ETS). Typical latency <5ms — well under the <100ms target for clock-in, the hottest caller.

Cache invalidation: credential_updated event triggers per-node cache refresh via PubSub.

Alternatives Considered

Alternative Rejected because
Event-based compliance check (async) By the time the event fires, the write has happened. Non-compliant worker is already rostered. Fails the requirement
Middleware / plug layer Too coarse — the plug would need domain-specific knowledge of which requests need which checks; violates domain isolation
Compliance as a separate microservice ADR-002-F rules out microservices; also adds network-call latency to the hot path
Deny-by-default with async revoke Creates a two-state UX (tentatively assigned / confirmed) that's confusing; doesn't help for timekeep (the hours already happened)
Multiple synchronous exceptions (rate card lookup, credential metadata) Considered in Part 2; rejected. One exception is load-bearing discipline; a second would erode the events-only rule (Commandment #31)

Consequences

Positive:

  • Non-compliant workers cannot be rostered, timed, paid, billed, or assigned vehicles — architecturally, not by policy alone
  • Finnest matches Fasttrack360's deepest moat (B12 C1)
  • Supervisors, payroll staff, and clients get deterministic behaviour — no "oops, that one slipped through"
  • Legal exposure reduced — the system refuses to create the compliance violation in the first place
  • Agents cannot bypass — propose actions go through the gated context, not around it

Negative:

  • Tight coupling of 5 domains to finnest_compliance for reads
  • finnest_compliance is load-bearing — outage affects 5 downstream domains
  • Hot path performance depends on ETS cache — cache miss costs DB round trip
  • Exception to AR-07 must be remembered — future developers will be tempted to add more exceptions

Mitigations:

  • finnest_compliance.Supervisor starts early in supervision tree — before domains that call it
  • Finnest.Compliance.CredentialCache (ETS) is populated at boot; cache miss is warm-cache path, not cold
  • Architecture test asserts only 5 documented caller sites use synchronous Compliance.check/2
  • Any attempt to add a 6th caller triggers ADR update (not silent extension)

Tipping points for re-evaluation:

  • 6th call site emerges as genuinely requiring a synchronous gate → amend this ADR, update AR-18
  • Compliance read latency becomes a problem (unlikely — ETS hit is µs)
  • Alternative pattern emerges (e.g. domain-embedded compliance checks with shared rules engine) that genuinely improves on this

Relationship to Guardrails

Enforces: AR-18 (Compliance.check/2 called synchronously before gated writes), B12 C1 (competitor parity), D3 (compliance auto-blocking driver).