Licenses and Permits
Track licenses and professional permits with a responsible owner, a five-tier expiration alert cadence, and a four-step renewal wizard with OCR expiry.
The Licenses module is Muin’s register of business licenses, professional permits, regulatory registrations, and certifications. Each license has a responsible owner, alert cadence tiers, renewal history, a linked calendar event, and an OCR pipeline that extracts the expiry date from the uploaded certificate so nobody has to type it twice.
If a license lapses because nobody was watching the calendar — the whole organization can be operating illegally for days, weeks, or months. This module exists so that doesn’t happen.
The Licenses List
On /compliance/licenses, each row shows:
| Column | What it shows |
|---|---|
| Name | Business-facing name (e.g. “California Seller’s Permit”) |
| License number | Issuing agency’s reference |
| Status | active, expiring_soon, expired, suspended, revoked, pending |
| Issuing authority | State / city / professional board |
| Expiration date | Next expiry |
| Days to expiry | Live countdown, colored by tier |
| Responsible user | Accountable owner |
Filters let you narrow by status, authority, category, or the assigned owner. The default filter excludes expired licenses that have been superseded by a renewal — see the renewal-filter note in the FAQs.
Creating a License
Click New License. The form asks for:
| Field | Purpose |
|---|---|
| Name | Friendly descriptor |
| License number | Issuing agency’s number |
| Category | business, professional, regulatory, environmental, operational, other |
| Issuing authority | The regulator or agency |
| Issue date | When first issued |
| Effective date | When this version takes effect |
| Expiration date | Next expiry |
| Alert days | Days before expiration to fire alerts — defaults to [90, 60, 30, 14, 7] |
| Responsible user | Who gets the alerts |
| Annual fee | Monetary cost for reporting |
| License document | The PDF / image scan of the certificate |
| Renewal lead days | How many days before expiration the renewal workflow unlocks |
The alert_days field is a JSONB list on the license model (ExpirationMixin.alert_days) — per-license override is allowed if a particular jurisdiction wants a different cadence. The default matches the standard “90-60-30-14-7 urgency ramp” that works across most regulatory bodies.
The Five Alert Tiers (R11-EXPIRATION-WORKER)
A daily worker at 08:00 UTC walks every active license. For every tier in alert_days that the license’s time-to-expiry hits today, the worker:
- Fires a notification to the
responsible_userviaNotificationDispatcher - Creates a
HumanTask(category=LICENSE_RENEWAL)pointing to the license detail page - Stamps
license_alert_sent[tier]with the send timestamp (a JSONB@>containment check) so re-runs of the worker on the same day are no-ops
Idempotency
The license_alert_sent JSONB map is the worker’s idempotency ledger. It keys by tier:
{
"90": "2026-01-15T08:00:00Z",
"60": "2026-02-14T08:00:00Z",
"30": "2026-03-16T08:00:00Z"
}
If the worker retries (e.g. after a transient failure), it sees the tier already stamped and skips the dispatch. Editing the alert_days list afterwards doesn’t reset the map — so adding a new 45 tier to a license that’s already past the 45-day mark doesn’t trigger a missed-alert blast.
When the Map Is Reset
The map is reset to {} on renewal — the license’s next cycle starts fresh, so the new 90-day mark will fire even if the previous cycle’s 90-day mark was already stamped.
Priority Mapping
| Tier (days to expiry) | HumanTask priority |
|---|---|
| 7 | CRITICAL |
| 14 | HIGH |
| 30 | MEDIUM |
| 60 | MEDIUM |
| 90 | LOW |
Tuned so the 7-day-out alert pages the responsible user through whatever high-priority routing the tenant has configured (escalation emails, Slack @channel, etc.).
The Renewal Wizard (IS19)
Click Renew on a license within its renewal window. Muin opens a four-step wizard:
Step 1 — Pre-Fill
The wizard opens with the current license fields pre-populated. Review and adjust:
- License number (if the agency assigns a new one on renewal)
- Effective date (first day of the new period)
- Expiration date (placeholder — gets overwritten after Step 3)
- Annual fee amount
- Notes
Step 2 — Receipt Upload
Upload the fee receipt from the issuing agency. This becomes part of the renewal history record. The upload goes through the standard Documents module pipeline (virus scan, versioning, retention tagging).
Step 3 — Certificate Upload + OCR
Upload the new license certificate (the renewed PDF / image from the agency). Muin’s Document Intelligence pipeline routes it to the license_cert extractor which pulls:
- License number (verified against the pre-filled value; warns on mismatch)
- Effective date
- Expiration date — populates the form’s expiration field
The OCR suggestion is editable — if the extractor misreads a date (handwritten certificates, low-quality scans), correct it before submit.
Step 4 — Submit
Review the final values and click Confirm Renewal. The service performs an atomic update:
- Records the renewal in
renewal_historywith previous + new expiration + fee + notes - Updates effective and expiration dates
- Resets
license_alert_sentto{} - Clears the old calendar event reference so the next worker pass creates a fresh one
- Bumps
version(optimistic lock counter) - Emits a
compliance.license.renewedlog event
Optimistic Locking (G-Licenses-5)
The atomic update is gated on version = :expected_version and bumps the counter in the same statement. If two owners click Renew simultaneously, only the first commit wins. The loser either:
- Re-reads and returns idempotent success if its payload matches what’s now committed (same effective_date, expiration_date, fee)
- Raises
409 LicenseRenewalConflictErrorif the payloads diverge
This keeps renewals race-safe without needing global locks.
Renewal Window Gate (G-Licenses-2)
You can’t renew a license that isn’t within its renewal window. The service raises:
License 'California Seller's Permit' is not within the renewal window of
90 days (expires 2026-07-15). Renewal is only allowed from 90 days before
expiration.
This prevents accidentally renewing a license 6 months early because someone clicked the wrong row.
Expired licenses can still be renewed — the window rejection only blocks premature renewals.
SOC 2 Evidence Auto-Update (JG-9)
When a license is renewed, the compliance.license.renewed event is consumed by the SOC Audit subscriber. If the license is linked to a framework control (typically via the compliance module mapping), the subscriber:
- Updates the evidence freshness date for the linked control
- Attaches the new license document as current evidence
- Marks the previous license document as archived evidence (retained for audit)
This means the auditor’s evidence-collection view stays current automatically — no manual “I renewed the license, now I need to remember to update the SOC 2 control” step.
Linking is configured on the license detail page under Linked Controls.
Calendar Integration
Each active license has a linked calendar event (calendar_event_id) representing the expiration date. The expiration worker regenerates this event on renewal so the calendar always shows the current cycle. Events appear on:
- The responsible user’s personal calendar (via the tenant’s Calendar integration)
- The tenant-wide Compliance Calendar at
/compliance/calendar(if enabled)
Deleting a license removes its calendar event; renewing it moves the event to the new expiration date.
FAQs
How do reminders work?
A daily worker fires at 08:00 UTC. For each license, it compares today to expiration_date - tier for every tier in alert_days (default [90, 60, 30, 14, 7]). If today matches any tier and that tier hasn’t already been stamped in license_alert_sent, the worker dispatches a notification + HumanTask to the responsible user. Priority ramps from LOW (90-day) through CRITICAL (7-day). Re-runs of the worker on the same day are idempotent via the JSONB containment check.
What if the OCR gets my expiry date wrong?
The extracted value is a suggestion — it populates the form, but the field is editable. Review before submit. If you renew a license with a wrong expiry date, the renewal is atomic (either fully applied or not at all) and the renewal_history retains the submitted value, so correcting it is a matter of entering a new renewal with the right date. Common OCR pitfalls: handwritten dates on old certificates, low-contrast scans, non-US date formats — adjust manually when those bite.
How do I renew manually?
If the renewal wizard doesn’t fit your flow (e.g. you renewed through the agency’s portal months ago and you’re just catching up Muin), you can update the license directly: edit the license, change effective + expiration dates, save. The license_alert_sent map clears on that update so the next cycle’s alerts fire clean. The renewal wizard is preferred because it captures receipt + OCR-verified expiry + SOC 2 auto-update — but manual updates are a supported fallback.
Why can’t I renew a license that expires in six months?
The renewal window guard (G-Licenses-2) rejects renewals outside renewal_lead_days before the expiration. The default is 90 days; you can raise it per-license on the edit form if your jurisdiction allows earlier filing. The rejection error message includes the exact window so you know when the renewal unlocks.
What happens to the alert map when I change alert_days?
Nothing — the map keys by tier value. Changing the tier list doesn’t reset the map. If you add a tier (say, 45), the worker will fire for 45 the next time a license’s time-to-expiry hits that value. If you remove a tier (say, removing 90), stamps for the removed tier are harmless leftovers — they don’t fire anything. Resets only happen on renewal.
Related
- Compliance Health — license compliance is 5% of the score
- Compliance Frameworks — link a license to a framework control
- SOC 2 audit — how license renewal auto-refreshes evidence (JG-9)
- Document assembly — the Documents pipeline that processes uploaded certificates