The Authorization Gate: Modeling Multi-Party Workflows in Rails
Some software problems are hard because the algorithm is hard. Most business software is hard for a duller, more expensive reason: several parties who don’t work for the same company have to hand one thing back and forth without dropping it. A home-modification network for older adults is a clean example. A member has an unsafe home. A clinician assesses it. An installer does the physical work. A health plan authorizes and pays. Four parties, one work order, and a dozen ways for it to go wrong — the most expensive being an installer showing up and bolting a $3,400 stair lift to a staircase the plan never agreed to cover.
I built a small Rails app to model exactly this, and the whole thing turns on one invariant. It’s worth pulling apart, because the pattern generalizes to any workflow where a state change costs real money or carries a compliance obligation.
The shape of the problem
The work order moves through a fixed sequence of states:
assessed → approved → scheduled → installed → verified
↘ denied
Each edge is driven by a different role. The plan reviewer approves or denies.
The installer schedules and completes. The care coordinator verifies. And the
transition everyone actually cares about — approved → scheduled — is the point
where the plan’s money gets committed. That edge is the authorization gate:
nothing gets scheduled for install until the health plan has said yes.
The naive version of this lives in a controller: check a boolean, render an error if it’s false. That works right up until a second code path — a background job, a bulk importer, an admin action, a future teammate’s new feature — schedules a work order without going through that controller. The invariant wasn’t really enforced; it was enforced in one place, which is a different and weaker thing.
Put the invariant in the model
The rule “you cannot schedule an unauthorized work order” is a fact about the domain, not about a request. So it belongs on the model, where every code path has to go through it:
def schedule!(actor:, installer:, on:)
unless authorized?
raise IllegalTransition,
"cannot schedule ##{reference_code}: not authorized by the health plan"
end
transition!(to: "scheduled", actor: actor,
detail: "installer=#{installer.name} on #{on}") do
self.installer = installer
self.scheduled_for = on
end
end
Two guards, deliberately redundant. The state machine already forbids
assessed → scheduled (you can only reach scheduled from approved). But I also
check authorized? explicitly, because authorized_at is set as part of approval
and I want the reason the transition failed to be legible at the call site. When
an invariant protects money, belt-and-suspenders is cheaper than the incident.
transition! is the shared engine underneath every state change:
def transition!(to:, actor:, detail: nil)
from = status
unless TRANSITIONS.fetch(from, []).include?(to)
raise IllegalTransition, "##{reference_code}: cannot go #{from} -> #{to}"
end
required_role = ROLE_FOR[to]
if required_role && actor.role != required_role
raise IllegalTransition,
"#{actor.role} may not #{to} ##{reference_code} (needs #{required_role})"
end
raise IllegalTransition, "actor from a different plan" if actor.health_plan_id != health_plan_id
transaction do
yield if block_given?
self.status = to
save!
audit_events.create!(actor_name: actor.name, actor_role: actor.role,
action: to, from_status: from, to_status: to,
detail: detail, occurred_at: Time.current)
end
end
Three checks — the state edge, the actor’s role, the actor’s tenant — and then a transaction that flips the status and writes the audit row together. That co-transaction is the second load-bearing decision.
The audit trail is not a log
In a multi-party, regulated workflow, “who approved this, and when” is not debugging output — it’s the evidence. So the audit event is written in the same transaction as the state change. If the save rolls back, the audit row rolls back with it. And critically, a rejected transition writes nothing:
test "a rejected transition writes no audit event" do
err = assert_raises(WorkOrder::IllegalTransition) do
@wo.schedule!(actor: installer, installer: installer, on: 3.days.from_now)
end
assert_equal "assessed", @wo.reload.status
assert_equal 0, @wo.audit_events.count
end
The trail records what happened, never what was attempted and denied — those
are different facts, and conflating them produces an audit log nobody can trust.
The events themselves are immutable: AuditEvent raises on update and destroy.
An audit trail you can edit is just a suggestion.
Multi-tenancy is a scoping habit, not a feature
Every health plan is a tenant. The isolation isn’t a module you install; it’s a discipline you keep: every lookup goes through the current plan.
def current_plan_scope
Current.health_plan || raise(ActiveRecord::RecordNotFound)
end
# in the controller:
@work_order = current_plan_scope.work_orders.find(params[:id])
Guessing another plan’s work-order id returns a 404, not another tenant’s data — and there’s a test that asserts exactly that. The transition engine reinforces it from the other side: an actor can’t act on a work order outside their own plan, even if you somehow handed them the object.
What this buys you
The payoff isn’t elegance for its own sake. It’s that the expensive mistakes become unrepresentable. You cannot schedule an unauthorized install. You cannot approve as an installer. You cannot see another plan’s members. You cannot rewrite history. None of those are enforced by a code review catching it — they’re enforced by the model refusing to do the wrong thing, with a test proving the refusal.
When four parties share one work order, that’s the difference between software that coordinates them and software that just hopes.