Why Status Is a Function, Not a Column
How I built a UAE document compliance engine for 178+ employees — and why storing computed state would have made it worse.
The Manpower Management System tracks UAE document compliance for 178+ employees across 6 companies. Five mandatory document types — Passport, Residence Visa, Emirates ID, Work Permit, ILOE Insurance — each with their own fine schedules, grace periods, and alert windows. The question at the start of the project: where does document status live?
The obvious answer: a `status` column. Run a nightly cron, recompute, update. That's what most people build.
I didn't do that.
The problem with storing derived state
A stored status is a snapshot. It was correct as of the last cron run. A recruiter opening the dashboard at 9 AM on the exact day an Emirates ID expires — what do they see? Yesterday's "Valid." That's wrong, and wrong in a way that matters: UAE document violations carry real financial penalties.
More practically: stored state drifts. Grace periods mean the same document can move through five states over weeks without any data changing. Storing that requires either running the cron very frequently (expensive, complex) or accepting staleness (wrong).
The function
Status is always derived at read time. Five inputs, one output:
func ComputeStatus(
expiryDate *time.Time,
graceDays int,
alertDays int,
docNumber string,
now time.Time,
) string {
if expiryDate == nil {
return StatusIncomplete
}
// severity-first: penalty/grace/expiring before incomplete
// boundary rule: valid THROUGH expiry day (grace starts day after)
switch {
case daysUntilExpiry < 0 && (graceDays == 0 || -daysUntilExpiry > graceDays):
return StatusPenaltyActive
case daysUntilExpiry < 0 && graceDays > 0 && -daysUntilExpiry <= graceDays:
return StatusInGrace
case daysUntilExpiry >= 0 && daysUntilExpiry <= alertDays:
return StatusExpiringSoon
}
if docNumber == "" {
return StatusIncomplete
}
return StatusValid
}No DB reads. No network calls. No side effects. You can call this in a tight loop over hundreds of employees and it's just arithmetic.
The boundary bug, and why it was easy to fix
Early on, documents were flipping to "expiring_soon" one day before they should. The dashboard metric cards showed "Valid" while the alerts list showed "Expiring Soon" for the same document — because they called ComputeStatus at slightly different times, and one compared with `<` while the other used `<=`.
The UAE rule is: a document is valid through its expiry day. The morning of the expiry day, it's still valid. The following morning, it's in grace (or expired, depending on the document type). My original comparison was treating expiry day itself as the first grace day.
Fix: strip time components from both `now` and `expiryDate` before comparison. The function now works only with dates, not datetimes. One change, one place, instantly consistent across the entire system.
If status had been a stored column, this bug would have required a migration, a backfill, a cron rerun, and careful verification that the update didn't race with reads. As a function, it was a one-line fix and a deploy.
The UAE fine schedule
| Document | Grace | Fine Type | Rate | Cap |
|---|---|---|---|---|
| Passport | 0 days | Daily | AED 0 | — |
| Residence Visa | 0 days | Daily | AED 50/day | — |
| Emirates ID | 30 days | Daily | AED 20/day | AED 1,000 |
| Work Permit | 50 days | One-time | AED 500 | AED 500 |
| ILOE Insurance | 0 days | One-time | AED 400 | AED 400 |
The fine calculation follows the same pattern — pure function, injected `now`. Monthly fine types are rounded up to the nearest month. Caps are applied after the full calculation. All configurable per-company through an admin UI backed by a `compliance_rules` table.
Multi-tenant isolation
Six companies share one database. A user authenticated as a company_owner for Company A must never see Company B's employees, documents, or compliance data — even if they know the IDs.
The solution is an `InjectCompanyScope` middleware that runs after JWT auth. It reads `user_companies` (the join table mapping users to companies they can access), and attaches the allowed company IDs to the request context. Every handler that touches company-scoped data reads that context and filters by it.
// InjectCompanyScope middleware — runs after Auth, before handlers
func InjectCompanyScope(pool *pgxpool.Pool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userRole, _ := r.Context().Value(ctxkeys.UserRole).(string)
// admins see everything — skip the lookup
if userRole == "admin" || userRole == "super_admin" {
next.ServeHTTP(w, r)
return
}
userID, _ := r.Context().Value(ctxkeys.UserID).(string)
rows, _ := pool.Query(r.Context(),
`SELECT company_id::text FROM user_companies WHERE user_id = $1`, userID)
// ... collect IDs, attach to context
ctx := context.WithValue(r.Context(), ctxkeys.CompanyScope, ids)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}A company_owner who crafts a URL with another company's employee ID gets an empty response — the SQL always has `AND company_id = ANY($n)` baked in, and the allowed IDs come from the context, not the request.
What the pure-function approach bought me
- ›Zero cron complexity — no scheduled jobs, no drift, no "what if the cron fails?"
- ›Instant correctness — status at 11:59 PM and 12:00 AM are both right for their respective moments
- ›Trivial testability — pass in a fixed `now`, assert the output. One function, no mocks.
- ›Fast bug fixes — the boundary issue was a one-liner because the logic had one home
- ›Fine calculation is auditable — given any point in time, you can explain exactly how the fine was computed
The system is in production for a UAE manpower firm managing 6 companies. Compliance dashboards run fast because status is computed in Go, not fetched from a stale column. Documents never show the wrong state.