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_compliancefor reads finnest_complianceis 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.Supervisorstarts early in supervision tree — before domains that call itFinnest.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).