ID Cards Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for ID Cards.
ID Cards Features and Flows
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Backend doc | id-cards/backend.mdx, apps/api/src/modules/id-cards/** | Services, transactions, side effects, state transitions |
| API | id-cards/api.mdx, controllers | Route surface, actors, permissions |
| Pure rules | apps/api/src/utils/id-cards/id-card-rules.util.ts, id-card-design.ts | Validity math, verdict logic, design schema |
| Schema | packages/db/src/schema/school/id-cards.ts, migrations 0033/0034 | Constraints that ARE the business rules (duplicate guard, append-only history) |
2. Feature Summary
| Field | Value |
|---|---|
| Module | IdCards |
| Submodule | Templates, Assets, Settings, Generation, Cards, Verification, Workers |
| Primary user value | Design a card once, generate it correctly for every student/teacher/staff member, and give front-office and gate staff a fast, permission-scoped way to verify who is holding it. |
| Actors | Admin (template designer, front-office operator, gate/security operator), worker/system (generation runner, sweeps), guest (public verification only) |
| Main entry points | /api/id-card-templates*, /api/id-card-generation*, /api/id-cards*, /api/id-card-scan, /api/id-card-verification/*, the two crons, the BullMQ processor |
| Main outputs | Card and template records, rendered card images (print/download), append-only card history events, six notification kinds, printed cards carrying a QR code |
| Related docs | API, Backend |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
| Guest | View the public verification page and photo for a card whose token they possess | List cards, search by name, see guardian contacts, see anything without a valid token | None | Rate-limited 60/min/IP; every response carries no-store/noindex/no-referrer headers. |
| Admin — template designer | Design, publish, activate/deactivate, archive, assign, and default templates; upload/delete assets | Generate a card without IdCards_CREATE; publish an invalid design | JWT + IdCardTemplates_* | A published version is immutable — editing the draft afterward never touches an already-generated card. |
| Admin — front office | Generate cards (single/bulk), issue, print/reprint, replace, cancel, view dashboards and history | Report an incident without IdCardIncidents_UPDATE; see guardian contacts without Guardians_READ | JWT + IdCards_*, IdCardIssue_UPDATE, IdCardPrint_UPDATE, IdCardReplace_CREATE | Every reader also needs a people-read code (Students_READ/Staff_READ) — IdCards_READ alone shows nobody. |
| Admin — gate/security operator | Scan a card by token or number and see enough to verify identity | See guardian contacts without Guardians_READ; get full detail from a card-number-only lookup | JWT + IdCardVerify_READ | Rate-limited 120/min/account. The single most sensitive read in the module. |
| Admin — superadmin | Everything above, plus configure public verification | — | JWT + IdCardPublicVerification_UPDATE | The only role that can turn the public page on or change what it shows. |
| Worker/system | Process bulk generation jobs; run the two sweeps | — | Internal (BullMQ) | Idempotent per lease token and per bucketed cron job id. |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Design a card layout | Admin | Designer | PATCH /id-card-templates/:publicId | Draft design | draft_design, draft_hash | API §8.1-adjacent |
| Publish an immutable version | Admin | Designer | POST .../publish | Draft design | New id_card_template_versions row | API §4 |
| Scope a template to a grade/section/department | Admin | Designer | POST /id-card-template-assignments | Existing assignments | New assignment row | API §4 |
| Upload a logo/seal/signature | Admin | Designer | POST /id-card-assets | — | New asset row, storage bytes | API §4 |
| Review generation candidates and readiness | Admin | Front office | GET /id-card-generation/candidates | Students/staff, existing cards | none | API §4 |
| Generate one card | Admin | Front office | POST /id-card-generation/cards | Template, settings, person | id_cards, id_card_events | API §4 |
| Submit a bulk generation job | Admin | Front office | POST /id-card-generation-jobs | — | Job + item rows, outbox event | API §8.1 |
| Cancel/retry a job | Admin | Front office | POST .../cancel, .../retry | Job row (locked) | status, item statuses | API §8.4, §8.5 |
| Mark a card printed / reprint | Admin | Front office | POST /id-cards/print/* | Card row | printed_at, print_count | API §4 |
| Issue a card | Admin | Front office | POST /id-cards/issue | Card row, holder's prior active card | status = active, closes prior card | API §8.7 |
| Report an incident | Admin | Front office | POST /id-cards/:publicId/incidents | Card row | status = lost/stolen/damaged, closed_at | API §4 |
| Generate a replacement | Admin | Front office | POST /id-cards/:publicId/replacements | Predecessor card | New id_cards row, predecessor replaced | API §4 |
| Cancel a card | Admin | Front office | POST /id-cards/:publicId/cancel | Card row | status = cancelled | API §4 |
| Scan a card | Admin | Gate operator | POST /id-card-scan | Card, holder, guardians (gated) | id_card_events (verified) | API §8.8 |
| Verify publicly | Guest | Anyone with a token | GET /id-card-verification/:token | Card, settings' public-show flags | id_card_events (verified) | API §8.9 |
| Recover a stuck generation job | Worker | System | 5-minute cron | Jobs with expired lease | recovery_count, re-enqueue | Backend §7.4 |
| Expire, notify, deactivate, prune | Worker | System | Hourly cron | Active cards, leavers, old verified events | status, expiry_notified_at, event deletion | Backend §7.4 |
5. User-Facing Flows
5.1 Designing and Publishing a Template
Summary
A designer creates a draft template for a card type, lays out front and back sides in millimetres, references uploaded assets and dynamic field keys (photo, name, roll number, guardian name, etc.), validates it, and publishes an immutable version before it can be used for generation.
Preconditions
- Caller holds
IdCardTemplates_CREATE/_UPDATE. - For a student template bound to a session, the session must exist.
- Any asset referenced in the design must already be uploaded.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Designer | Creates a draft with a card type and orientation | New draft template | IdCardTemplatesService.create |
| 2 | Designer | Edits draftDesign (elements, positions, field keys) | Design validated structurally | id-card-design.ts |
| 3 | Designer | Calls validation to check for errors before publishing | Validation report | GET .../validation |
| 4 | Designer | Publishes | Immutable id_card_template_versions row; template flips to active/inactive | IdCardTemplatesLifecycleService.publish |
| 5 | Designer | Optionally sets the template as default for its card type | is_default = true, unset on any prior default | setDefault() |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Invalid design | An element overlaps the safe margin, or references a missing field key | Reject publish | ID_CARD_TEMPLATE_INVALID_DESIGN |
| Design too large | Serialized JSON exceeds the size cap | Reject publish | ID_CARD_TEMPLATE_DESIGN_TOO_LARGE |
| Name collision | Another live template shares the name (case-insensitive) | Reject create/rename | ID_CARD_TEMPLATE_NAME_TAKEN |
| Default on non-active | Attempted to default a draft/inactive/session-bound template | Reject | ID_CARD_TEMPLATE_DEFAULT_NOT_ALLOWED |
| Archive in use | A card still references one of this template's versions | Reject archive | ID_CARD_TEMPLATE_IN_USE |
| Duplicate action | Publishing twice with no draft change | Each publish creates a NEW version regardless — versions are never deduplicated by content | New version_no each time |
5.2 Single Card Generation
Summary
Front office generates one person's card immediately — used for a walk-in replacement need or a single late enrolment, where waiting for a bulk batch is unnecessary.
Preconditions
- Caller holds
IdCards_CREATEand a people-read code for the target's type. - Readiness review passes (required photo, roll number, etc., per settings).
- Person holds no active or pending card already (unless this call is itself a replacement, which is a separate route).
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Front office | Selects a person and (optionally) a template | Candidate validated | IdCardGenerationController.candidates/.people |
| 2 | Backend | Runs readiness review | Pass/fail with reasons | IdCardReadinessService |
| 3 | Backend | Resolves the template (explicit, or via assignment rules / default) | Active, matching-type template resolved | IdCardTemplateResolverService |
| 4 | Backend | Computes validity window (session-based for students, months-based for staff) or accepts an override | validFrom/validUntil set | studentValidity()/staffValidity()/checkValidityOverride() |
| 5 | Backend | Generates the card number from the sequence + settings prefix/padding | card_number assigned | id_card_number_seq |
| 6 | Backend | Writes the card and its generated event | New id_cards row | IdCardGenerateService |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Active card exists | Person already holds an active card | Reject | ID_CARD_ACTIVE_CARD_EXISTS |
| Pending card exists | Person already holds generated/printed | Reject | ID_CARD_PENDING_CARD_EXISTS |
| Readiness failure | Required photo/roll number missing | Reject | ID_CARD_PERSON_NOT_READY |
| Session ended | Student's session end date has passed | Reject | ID_CARD_SESSION_ENDED |
| Bad override | Administrator-supplied validity fails checkValidityOverride (incomplete, inverted, already ended, too long, beyond session grace) | Reject | ID_CARD_VALIDITY_INVALID |
| Duplicate action | Same request retried after a transient failure | Not idempotent at this route — the caller must check state before retrying | N/A |
5.3 Bulk Generation Job
Summary
Front office selects up to 2000 people (a whole grade, a whole department) and submits one job. The job runs asynchronously; each person's outcome is tracked independently so one failure never blocks the batch.
Preconditions
- Caller holds
IdCards_CREATE. - Caller has fewer than the per-account cap of open jobs.
clientRequestIdis a fresh UUID (or a deliberate replay of a known one).
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Front office | Selects people, template, existing-card policy | Job payload assembled | Client |
| 2 | Backend | Takes a per-user advisory lock, checks the open-job cap | Pass or ID_CARD_JOB_LIMIT_REACHED | IdCardGenerationJobsService.create |
| 3 | Backend | Writes the job + one item per person + an outbox event, in one transaction | queued job | Schema §5.3 |
| 4 | Outbox dispatcher | Relays the job onto BullMQ | PROCESS_GENERATION_JOB enqueued | Outbox invariant |
| 5 | Worker | Claims a lease, sets running, processes each pending item in its own transaction | Item outcomes recorded incrementally | IdCardGenerationRunnerService |
| 6 | Worker | Sets the final job status from the item outcomes | completed / completed_with_errors / failed | same |
| 7 | Backend | Notifies the requester | In-app/email notification | IdCardNotifierService |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Open-job cap | Caller has too many open jobs | Reject at submit | ID_CARD_JOB_LIMIT_REACHED |
| Concurrent submit, same user | Two submits race the cap | Serialized by an advisory lock, so the cap cannot be raced | Second submit sees the lock, then a fresh (correct) count |
| Duplicate submit | Same clientRequestId resubmitted | Returns the original job, no new row | ID_CARD_JOB_DUPLICATE_REQUEST semantics (idempotent, not an error to the caller) |
Person already has a card, policy skip | Encountered mid-run | Item marked skipped with an outcome code; job continues | Non-fatal to the batch |
Person already has a card, policy replace | Encountered mid-run | Item generates a replacement card, closing the old one | Same as §5.5 |
| Worker crash mid-job | Lease expires before the worker finishes | The 5-minute recovery sweep reclaims the job and resumes it | Backend §7.4 |
| Cancel mid-run | Caller cancels while running | Job moves to cancelling, then cancelled once the worker observes it between items | API §8.4 |
| Retry after partial failure | Caller retries a completed_with_errors job | Failed/skipped items reset to pending and re-enqueued; successfully generated items are untouched | API §8.5 |
| Row-scope mismatch | A caller with a restricted people scope tries to read/act on another user's job | 404, indistinguishable from "does not exist" | Backend §6.3 |
5.4 Issuing a Card
Summary
Front office hands a physical card to its holder. This is the moment the card becomes the holder's live, active credential.
Preconditions
- Card is
generatedorprinted(or, if settings allow issuing without printing,generatedalone suffices). - The card's
validFromis not in the future.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Front office | Confirms the physical handover | Issue request sent | Client |
| 2 | Backend | Checks the card is in an issuable state and its validity window has begun | Pass or reject | IdCardIssueActionService |
| 3 | Backend | If the holder has a prior active card, closes it in the same transaction | Prior card's status changes away from active | Database's non-deferrable unique index requires this ordering |
| 4 | Backend | Sets the new card active, records issued_at | Card active | same |
| 5 | Backend | Writes an issued event and notifies | History + notification | id_card_events, IdCardNotifierService |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Not yet valid | validFrom is in the future | Reject | ID_CARD_NOT_YET_VALID |
| Wrong status | Card is not generated/printed | Reject | ID_CARD_INVALID_TRANSITION |
| Print required | allow_issue_without_print is off and the card was never printed | Reject | ID_CARD_INVALID_TRANSITION |
5.5 Incident and Replacement
Summary
A card is lost, stolen, or damaged; front office records the incident (which is irreversible — the card can never return to active) and generates a replacement, which supersedes the old card and carries its own history link back to the predecessor.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Front office | Reports the incident with a reason | Card status → lost/stolen/damaged, closed_at set | IdCardActionsController.reportIncident |
| 2 | Front office | Requests a replacement | New card generated, replaces_card_id set | IdCardReplacementActionService |
| 3 | Backend | Predecessor's status becomes replaced if not already terminal from the incident | History updated | Same transaction |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Non-incident source | Attempted to replace a card that is not in a replaceable state | Reject | ID_CARD_REPLACEMENT_SOURCE_INVALID |
| Already has a live replacement | A non-terminal replacement already exists for this predecessor | Reject | ID_CARD_REPLACEMENT_EXISTS |
| Incident reason on wrong route | lost/stolen/damaged supplied to the generic replacement route instead of the incident route | Reject | ID_CARD_REPLACEMENT_REASON_INVALID |
Reason other with no note | Reject | ID_CARD_REPLACEMENT_NOTE_REQUIRED | |
| Missing permission | Caller lacks IdCardReplace_CREATE | Reject | ID_CARD_REPLACE_PERMISSION_REQUIRED |
5.6 Scanning and Public Verification
Summary
A gate operator scans a printed card's QR (or types its number) to confirm the holder's identity; anyone who scans the QR without staff credentials reaches a minimal public page showing only what the school has chosen to expose.
Preconditions
- Guest flow:
qrVerificationEnabledandpublicVerificationEnabledare both on. - Staff flow: caller holds
IdCardVerify_READ.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Operator/Guest | Submits the token (scan) or code (staff) | Card resolved | Controllers |
| 2 | Backend | Computes the live verdict from status + dates + holder's current standing | valid/expired/deactivated/etc. | cardVerdict() + a live holder-status check |
| 3 | Backend | Applies disclosure rules (guardian contacts, full vs. basic detail, public field visibility) | Filtered response | IdCardScanService/IdCardVerificationPublicService |
| 4 | Backend | Records a verified event | History updated | id_card_events |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Deactivated holder | Holder left/deleted, sweep not yet run | Verdict deactivated immediately | Live check, not the stored status |
| Card-number lookup | Staff scan by number instead of token | Basic info only, no guardians, regardless of permissions | Full detail is token-only |
| Guardian gate | Caller lacks Guardians_READ | Guardian contacts omitted from scan response | Detail still returned otherwise |
| Public disabled | publicVerificationEnabled is off | 409 ID_CARD_VERIFICATION_DISABLED | |
| Invalid token | Malformed or unknown token | 404 ID_CARD_NOT_FOUND | Never distinguishes "malformed" from "unknown" to a guest |
Malformed :publicId/token shape on admin routes | Non-UUID path param | 400 at the parameter pipe, before any query | Consistent across the module |
6. Admin Flows
6.1 Create Template Assignment Rule
Route: POST /api/id-card-template-assignments, permission IdCardTemplates_UPDATE.
6.2 Archive a Template
Route: POST /api/id-card-templates/:publicId/archive, permission IdCardTemplates_DELETE.
6.3 Cancel a Generation Job (row-scoped)
Route: POST /api/id-card-generation-jobs/:publicId/cancel, permission IdCards_CREATE.
7. Lifecycle and State Transitions
7.1 Card Lifecycle
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
id_cards | (none) | Generate | generated | No active/pending card exists for the person; readiness passes | id_card_events (generated), notification if configured |
id_cards | generated | Mark printed | printed | — | printed_at, print_count += 1, event printed |
id_cards | printed (or generated if allowed) | Issue | active | validFrom not in the future; prior active card (if any) closes first | Event issued, notification |
id_cards | active | Report incident | lost/stolen/damaged | — | Event marked_*, closed_at, high-priority notification |
id_cards | active | Sweep detects expiry | expired | today > validUntil | Event expired, notification once (expiry_notified_at) |
id_cards | active | Sweep detects holder left | deactivated | auto_deactivate_on_leave on | Event deactivated |
id_cards | generated/printed/active | Cancel | cancelled | — | Event cancelled |
id_cards | any live state | Replacement generated | replaced | A successor card is created | Event replaced on this row; new row created |
Diagram:
Every non-[*] terminal state in this diagram is reached only once per card — lost/stolen/damaged never return to active, matching the schema's design: incidents are irreversible by construction, not by convention.
7.2 Generation Job Lifecycle
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
id_card_generation_jobs | (none) | Submit | queued | Under the open-job cap | Items created, outbox event |
id_card_generation_jobs | queued | Worker claims lease | running | Lease acquired | started_at, lease_token set |
id_card_generation_jobs | running | All items processed cleanly | completed | — | finished_at |
id_card_generation_jobs | running | Some items failed/skipped | completed_with_errors | — | finished_at |
id_card_generation_jobs | running | Unrecoverable error | failed | — | finished_at, failure_code |
id_card_generation_jobs | queued | Cancel | cancelled | — | Items → skipped |
id_card_generation_jobs | running | Cancel requested | cancelling → cancelled | Worker observes between items | — |
id_card_generation_jobs | failed/completed_with_errors | Retry | running | Not cancelled/cancelling | Failed items → pending, re-enqueued |
Diagram: see the state diagram in the backend doc §7.4 — it is the same machine.
7.3 Template Lifecycle
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Publish template | id_card_template_versions, id_card_templates | none | none | none | none | none |
| Generate single card | id_cards, id_card_events | none | none | none | none | none unless "ready for pickup" configured |
| Submit bulk job | id_card_generation_jobs, id_card_generation_job_items, outbox_events | none | PROCESS_GENERATION_JOB | none | none | on completion, to requester |
| Issue card | id_cards (this + prior card), id_card_events | none | none | none | none | id_card.issued |
| Report incident | id_cards, id_card_events | none | none | none | none | id_card.incident_reported (high priority) |
| Generate replacement | id_cards (new + predecessor), id_card_events | none | none | none | none | id_card.replacement_generated |
| Scan/verify | id_card_events (verified) | none | none | none | none | none |
| Lifecycle sweep | id_cards.status, expiry_notified_at, id_card_events (delete, pruned rows) | none | LIFECYCLE_SWEEP | none | none | id_card.expiring, id_card.expired |
| Recovery sweep | id_card_generation_jobs.recovery_count, re-enqueue | none | RECOVERY_SWEEP | none | none | none |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| Rate limited (scan) | More than 120 scans/min from one account | 429 | Wait for the window to reset | ID_CARD_SCAN throttle |
| Rate limited (public verify) | More than 60 requests/min from one IP | 429 | Wait for the window to reset | PUBLIC_CARD_VERIFY throttle |
| Rate limited (job submit) | More than 20 submissions/hour from one account | 429 | Wait, or reduce submission frequency | ID_CARD_JOB_SUBMIT throttle |
| Queue failure | BullMQ/Redis unavailable when a job's outbox event is ready to relay | Job stays queued; the outbox dispatcher retries on its own schedule | Automatic, once Redis recovers | Outbox invariant |
| Worker crash mid-job | Lease expires before completion | Job appears stuck running for up to 5 minutes | The recovery sweep reclaims and resumes it | IdCardRecoverySweepService |
| Version conflict | Concurrent edits to a template/settings/job/card | 409 with the current row's version | Re-fetch, reapply, resubmit | bump_row_version() triggers |
| Duplicate submit | Same clientRequestId sent twice | Original job returned, not an error | None needed — idempotent by design | id_card_generation_jobs_client_request_unique |
11. Diagrams Required Per Module
Covered above: actor/capability matrix (§3-4), high-level module flow (backend doc §3, §7), sequence diagrams per major flow (§5.1-5.6, §6), state machines for cards/jobs/templates (§7), data side-effect table (§9), error branch diagrams (§6.3, §5.6).
12. Mandatory Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Template publish | Publishing with no draft change still creates a new version | Designer | POST .../publish | New version number issued | New id_card_template_versions row | IdCardTemplatesLifecycleService |
| Template default | At most one default per card type, and only among active, session-agnostic templates | Designer | POST .../set-default | Prior default cleared automatically | Partial unique index enforces it in the DB | schema §5.3 |
| Template name | Name uniqueness is case-insensitive and only among LIVE (non-deleted) templates | Designer | Create/rename | An archived template's old name can be reused | id_card_templates_name_live_unique | schema |
| Asset deletion | Refused while any design still references the asset | Designer | DELETE /id-card-assets/:publicId | 409 ID_CARD_ASSET_IN_USE | No row change | API §4 |
| Bulk job idempotency | Resubmitting the same clientRequestId is a no-op, not an error surfaced to the caller | Front office | Network retry after a timeout | Original job's state returned | Unique index on (requestedByUserId, clientRequestId) | schema |
| Job cap concurrency | Two simultaneous submits from one user cannot both pass the cap check | Front office | Double-click submit | One succeeds, one sees the true post-first-submit count | Per-user pg_advisory_xact_lock taken before the count | IdCardGenerationJobsService.create |
| Job row scope | A caller with an unrestricted people scope for a card type can act on ANY job of that type, not only their own | Front office | List/detail/cancel/retry | Broader visibility for unrestricted scopes | IdCardScopeService | backend §6.3 |
| Job item scope | Items are filtered by people scope even when the parent job is visible | Front office | GET .../items | Fewer items than totalCount for a scoped caller | Same service | backend §6.3 |
| Issue closes prior card | Issuing a new card for someone who still holds an active one closes the old one FIRST, same transaction | Front office | POST /id-cards/issue | No moment where two cards are simultaneously active | Non-deferrable unique index ordering | schema §5.3 |
| Guardian omission | Detail response OMITS the guardian key rather than nulling it, for a caller without Guardians_READ | Front office | GET /id-cards/:publicId | Response shape itself reveals nothing | IdCardDetailService.detail | backend §6.4 |
| Preview empty guardian | The generation preview resolves guardian.* to empty strings instead of omitting the key | Front office | GET /id-card-generation/preview-data | Design placeholders render blank rather than breaking | IdCardPreviewService | backend §6.4 |
| Card-number scan limits | A card-number lookup on the scan endpoint never returns guardian data, regardless of permission | Gate operator | POST /id-card-scan with a number, not a token | Basic info only | IdCardScanService | API §8.8 |
| Live deactivation on scan | A scan/verify shows deactivated even if the hourly sweep has not yet recorded it on the row | Gate operator / guest | Any scan/verify of a leaver's card | Verdict is correct immediately | Live holder-status check in scan/verify services | in-flight behavior |
| Not-yet-valid issue | A card cannot be issued before its own validFrom | Front office | POST /id-cards/issue | 409 ID_CARD_NOT_YET_VALID | IdCardIssueActionService | in-flight behavior |
| Readiness excludes card fields | Fields filled only at generation (card.*) are never flagged as empty in the pre-generation readiness review | Front office | GET /id-card-generation/candidates | No false-positive "missing field" warnings | IdCardReadinessService | in-flight behavior |
| UUID path validation | Every :publicId/token-shaped param is validated before any query runs | Any caller | Any route with a path param | 400 on malformed input, not a 404 or 500 | Global parameter pipe | in-flight behavior |
| Event ordering under one transaction | id_card_events.created_at uses clock_timestamp(), not now() | System | Any flow writing two events in one transaction (e.g. issue + close) | Events order correctly by actual occurrence | schema id_card_events | schema |
| Retention prune scope | The lifecycle sweep may delete ONLY verified events, only under a session flag set for that transaction | System | Hourly sweep | Card history (transitions, incidents) is never prunable | id_card_events_append_only trigger | schema §5.3 |
| Sequence never reused | A card number burned by a failed generation attempt is never reissued | System | Any failed generation after nextval() | No two cards ever share a number, even across failures | id_card_number_seq | migration 0034 |
12.2 Business Process Diagram Pack
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| At most one active card per person | A person cannot hold two simultaneously valid credentials | Front office sees ID_CARD_ACTIVE_CARD_EXISTS instead of two live cards | Partial unique index id_cards_one_active_student/_staff | Generation route rejects with 409 | Cannot be raced by concurrent generators | schema constraint, exercised by any generation integration test |
| At most one pending card per person | A second generated/printed card for the same person before the first resolves would be confusing at pickup | ID_CARD_PENDING_CARD_EXISTS | Partial unique index | 409 | Same guarantee, database-level | schema constraint |
| Templates are immutable once published | A card must render identically for as long as it exists | Editing a draft after publish never changes issued cards | Append-only trigger on id_card_template_versions | Publish always creates a new version, never edits one | Guard trigger raises on direct UPDATE/DELETE | schema constraint |
| Incidents are irreversible | A lost/stolen/damaged card must never re-enter circulation as if nothing happened | Front office must generate a REPLACEMENT, not "un-report" | id_card_status enum has no edge back to active from those states | No "undo incident" route exists | Transition service refuses it | state diagram §7.1 |
| Card cannot be issued before its validity starts | A card's printed validity window is a promise about when it is usable | ID_CARD_NOT_YET_VALID | IdCardIssueActionService | 409 | in-flight behavior | — |
Guardian data requires Guardians_READ | A card carries a child's guardian contact details, which is more sensitive than the card itself | Front-office staff without the extra grant see cards but not who to call | IdCardDetailService, IdCardScanService | Response shape omits/blanks the field | Same | — |
| Card-number lookup is never full-detail | A printed card number is far more guessable than a 192-bit QR token | Gate staff must actually scan the QR for full detail | IdCardScanService | Response shape differs by lookup method | Same | — |
| Bulk jobs cap at 2000 people | Bounds worker transaction count and lease duration for a single job | Front office splits very large schools into multiple jobs | id_card_generation_jobs_total_range CHECK | 400 ID_CARD_SELECTION_TOO_LARGE | Schema-enforced upper bound | schema constraint |
| Job submission is per-account rate-limited (20/hour) | Each job can carry 2000 people; unlimited submission could overwhelm the worker | Front office paces bulk submissions | ID_CARD_JOB_SUBMIT throttle | 429 | Redis-backed IP/user throttle | — |
| Public verification is superadmin-gated | Deciding what the open internet learns about a child is the highest-stakes settings surface in the module | Only superadmin can turn it on or change its shown fields | IdCardPublicVerification permission module, SUPERADMIN_ONLY_MODULES | PATCH .../public-verification requires the code | Same | permission catalog |
| Verify URL is frozen per card | A later change to the school's verify domain must never break already-printed QR codes | Front office can safely change settings without reprinting existing cards | id_cards.verify_url set once at generation | Detail/render always uses the card's own frozen URL | Same | — |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Async bulk generation via BullMQ instead of a synchronous batch endpoint | Front office is never blocked waiting on a 2000-person request | Request handler stays fast; failures are per-item, not all-or-nothing | Synchronous loop in the request handler | A caller must poll for job completion instead of getting an instant result | Worker outage delays generation, mitigated by the recovery sweep |
| Guardian data omitted, not nulled, in unauthorized reads | A caller without the grant cannot infer that guardian data exists at all | Simpler response contract than per-field masking | Return guardian: null | A client naively checking if (guardian) behaves identically either way, so the distinction matters mainly for introspection/debugging | Low |
| Card-number scan is deliberately weaker than token scan | Limits what a guessed/leaked card number can expose | One endpoint serves two trust levels without duplicating logic | Two separate scan endpoints | Slightly less discoverable API shape (one endpoint, two response shapes) | Low |
| Public verification default-off, superadmin-gated | A school can choose never to expose anything about a child publicly | One clear owner for the riskiest configuration surface | Grant IdCardSettings_UPDATE holders the same power | Slower to enable per-school if an operator lacks superadmin | Mitigated — this is intentional friction |
| Incidents are a one-way transition | Prevents "un-reporting" a theft to quietly reactivate a card that may be compromised | Simpler state machine, no reverse edges to validate | Allow reverting an incident | An honest mis-click (reported lost by mistake) requires a NEW card, not a fix | Low — administrators are trained on this |
Sequence-based numbering, never MAX()+1 | A card's identity is never recycled, even after a failed attempt | Immune to race conditions between concurrent generators | Compute the next number from existing rows | Numbering has gaps after failures — cosmetic only | None functionally |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Bulk generation | Empty selection | personPublicIds: [] | Rejected before any DB write | 400 validation error | DTO validation |
| Bulk generation | First use for a school | No prior jobs exist | Behaves identically to any later job | Normal queued response | — |
| Bulk generation | Last item in the batch fails | Worker processes items sequentially | Job still finalizes with completed_with_errors | Item-level detail available via .../items | IdCardGenerationRunnerService |
| Bulk generation | Duplicate action | Same clientRequestId retried | No second job created | Original job returned | unique index |
| Bulk generation | Concurrent cancel + worker claim | Race between an admin's cancel and the worker's lease claim | Serialized on the job row's FOR UPDATE lock | One of the two wins deterministically; no split-brain state | IdCardTransitionService |
| Card issuance | Expired validity window at issue time | validUntil already passed for an administrator-overridden card | Issuable if validFrom <= today <= validUntil... but a card with validUntil in the past should not have been generated in the first place; the check only guards validFrom | ID_CARD_NOT_YET_VALID only guards the lower bound | in-flight behavior |
| Card issuance | Permission mismatch | Caller lacks IdCardIssue_UPDATE | 403 before any row is touched | RoleGuard | — |
| Card issuance | Guest limitation | N/A — no guest-issuable flow exists | — | — | — |
| Scan | Missing dependency | Card references a template version whose asset was later deleted | Render may show a placeholder for the missing asset; the card record itself is unaffected | Renderer degrades gracefully | IdCardRenderService |
| Scan | Cache stale/miss | N/A — module has no cache layer | Every read is live | — | backend §8 |
| Scan | Unsupported filter/sort | N/A — scan takes no query filters | — | — | — |
| Public verify | Rate-limit failure | More than 60/min from one IP | 429, no card data leaked | Throttle guard | PUBLIC_CARD_VERIFY |
| Template publish | Concurrent edits by two designers | Two PATCH calls race on the same draft | Second writer's version mismatches, gets 409 | ID_CARD_TEMPLATE_VERSION_CONFLICT | bump_row_version trigger |
| Template assignment | Section without grade | Scope specifies a section but no grade | Rejected — sections are shared across grades and would be ambiguous | 400 ID_CARD_ASSIGNMENT_SCOPE_INVALID | schema check |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Bulk generation submit | students/staff, existing cards, settings | id_card_generation_jobs, id_card_generation_job_items, outbox_events | none | PROCESS_GENERATION_JOB | publicId, status, totalCount, createdAt |
| Card issue | card row, holder's prior active card, settings | id_cards (this + prior), id_card_events | none | notification | status, issuedAt |
| Scan | card, holder, guardians (gated) | id_card_events (verified) | none | none | verdict, personName, guardians (gated) |
12.7 Experience Quality Checklist
- The doc explains what the actor is trying to accomplish (design once, generate correctly, verify quickly).
- The doc explains backend behavior the actor does not see (advisory locks, lease fencing, append-only guards, live deactivation checks).
- The doc covers minor flows and branches (idempotent resubmission, row scoping, guardian omission vs. blanking).
- The doc includes admin, worker, and guest flows.
- The doc explains business logic, tradeoffs, and rationale (§12.3, §12.4).
- The doc maps every flow to API routes and backend side effects.
- The doc includes diagrams appropriate to each flow type.
- The doc covers edge cases and failure recovery (§12.5, §10).
13. Completion Checklist
- Every feature, minor action, and submodule capability is listed (§4, §12.1).
- Every actor has allowed and forbidden behavior (§3).
- Every major and minor flow includes steps, branches, and diagrams (§5, §6).
- Every lifecycle has a transition table and state diagram (§7).
- Every flow links to the API and backend docs.
- In-flight behaviors (row scoping, guardian disclosure,
ID_CARD_NOT_YET_VALID, live deactivation, readiness exclusions, UUID validation, token redaction) are documented as the system's current behavior.
See Also
- API doc:
/docs/developer/id-cards/api - Backend doc:
/docs/developer/id-cards/backend